Compare commits
110
Commits
18a7292844
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9903194316 | ||
|
|
28eedea372 | ||
|
|
a34fa7ee08 | ||
|
|
c33b6e94fb | ||
|
|
dd60a095c6 | ||
|
|
7ce1da18ab | ||
|
|
5b8432caf4 | ||
|
|
ef50d87bd7 | ||
|
|
ed3529a9d7 | ||
|
|
f4d8210cc4 | ||
|
|
02fcbd3174 | ||
|
|
a0c8c3828c | ||
|
|
cab8dd290f | ||
|
|
c85a60d920 | ||
|
|
a2cf3bfe39 | ||
|
|
d84d7b0bec | ||
|
|
6acceec701 | ||
|
|
972d629df8 | ||
|
|
a12b596053 | ||
|
|
393d928b19 | ||
|
|
8f01b75bd6 | ||
|
|
4831147087 | ||
|
|
3b190988be | ||
|
|
f225d4fe4e | ||
|
|
ebade04e81 | ||
|
|
0c48b2e9c1 | ||
|
|
978fdae051 | ||
|
|
9e52e279dd | ||
|
|
71188a5117 | ||
|
|
692bcc2084 | ||
|
|
6b3b024726 | ||
|
|
b45ca01e8e | ||
|
|
86030cf474 | ||
|
|
4705de9602 | ||
|
|
4d30c654e9 | ||
|
|
e59297a404 | ||
|
|
dc96a1a547 | ||
|
|
ccfce0fa5d | ||
|
|
fbc04f8892 | ||
|
|
68c8dd91b9 | ||
|
|
d778883fe3 | ||
|
|
6ae70d8ade | ||
|
|
ac6adcd0f4 | ||
|
|
4f51fb6a83 | ||
|
|
bb5ca769db | ||
|
|
5b3dfe2a50 | ||
|
|
6ea08ea497 | ||
|
|
0cbd39d0d8 | ||
|
|
299343798f | ||
|
|
abba4d6da1 | ||
|
|
2d2dfd94c6 | ||
|
|
a590d190bd | ||
|
|
45562c23d9 | ||
|
|
4a63e273fc | ||
|
|
36e0887c73 | ||
|
|
aeea7a8fea | ||
|
|
e0ee1dc7b4 | ||
|
|
53f132d446 | ||
|
|
a0e461a0b7 | ||
|
|
4e2a7120d6 | ||
|
|
bfa8ee19b7 | ||
|
|
91bc5a8bf8 | ||
|
|
744f8e243b | ||
|
|
0f606e3ddf | ||
|
|
9a7d554183 | ||
|
|
5aa3b99316 | ||
|
|
1ffbf3d6b7 | ||
|
|
f717a9a2f7 | ||
|
|
9d890a290f | ||
|
|
c3c7a4c8cf | ||
|
|
b6ed5fd00d | ||
|
|
4b85dd0eea | ||
|
|
fa6779e87d | ||
|
|
61ac5baa6f | ||
|
|
5bf1a29f4a | ||
|
|
74e32ed6bf | ||
|
|
bdcf6a8d18 | ||
|
|
613a19bcca | ||
|
|
8d17a7b8d7 | ||
|
|
d15337088f | ||
|
|
bd275bc7f2 | ||
|
|
222cf1a8ad | ||
|
|
caf2c07565 | ||
|
|
c9e4ba519a | ||
|
|
41df233840 | ||
|
|
b54b9fdb53 | ||
|
|
5d3d958ca1 | ||
|
|
d2dac3470f | ||
|
|
afb80bbba8 | ||
|
|
945d80b232 | ||
|
|
c91ab16b9b | ||
|
|
bf15c2e9cb | ||
|
|
109d47d8d3 | ||
|
|
07fef4547c | ||
|
|
b61cf84082 | ||
|
|
fbc4158016 | ||
|
|
8a0b65c3fa | ||
|
|
6374ba9e8a | ||
|
|
bbae8a8c78 | ||
|
|
e45dbc2e19 | ||
|
|
7a7038e2c2 | ||
|
|
4488749d6f | ||
|
|
f6d39f01da | ||
|
|
5852479c21 | ||
|
|
d11a84a0d3 | ||
|
|
f2ef5a8834 | ||
|
|
80a056f720 | ||
|
|
87649e6e22 | ||
|
|
c9e21c082d | ||
|
|
a7b93ca34a |
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
name: marktguru
|
||||||
|
license: Apache-2.0
|
||||||
|
description: Query weekly supermarket offers, flyer discounts, and price comparisons across German supermarkets (ALDI Süd, ALDI Nord, Lidl, EDEKA, REWE, Kaufland, PENNY, Netto, dm, ROSSMANN, etc.) via the Marktguru API and local MCP server. Use when the user asks to check supermarket discounts, find deals in brochures/prospekte, compare grocery prices for a postal code (PLZ), or create shopping lists from current promotions without needing fragile PDF OCR.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Marktguru Supermarket Offers & MCP Server
|
||||||
|
|
||||||
|
> Query live promotional flyers and circular offers from German supermarkets and drugstores without brittle PDF scraping or layout OCR.
|
||||||
|
|
||||||
|
This skill is inspired by and expands upon the Home Assistant integration pattern (`thorschtn/ha-marktguru`), leveraging Marktguru's backend API (`api.marktguru.de`) to retrieve structured deal data for any German postal code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Supermarkets & Retailers
|
||||||
|
|
||||||
|
Marktguru covers **over 450 retail chains** across Germany. The primary supported chains are:
|
||||||
|
|
||||||
|
* **Discounter**:
|
||||||
|
* **ALDI SÜD** (`127153`) & **ALDI NORD** (`127139`)
|
||||||
|
* **Lidl** (`126679`)
|
||||||
|
* **PENNY** (`126765`)
|
||||||
|
* **Netto Marken-Discount** (`126735`) & **Netto (mit Hund)** (`126734`)
|
||||||
|
* **NORMA** (`126745`)
|
||||||
|
* **Supermärkte & Vollsortimenter**:
|
||||||
|
* **EDEKA** (`127093`) & **E center** (`126699`) + regionale Kaufleute (Simmel, Zurheide, etc.)
|
||||||
|
* **REWE** (`126802`) & **REWE Center** (`126804`)
|
||||||
|
* **nahkauf** (`137320`), **HIT** (`126598`), **tegut** (`126866`)
|
||||||
|
* **Großfläche / Hypermarket**:
|
||||||
|
* **Kaufland** (`126654`)
|
||||||
|
* **Globus** (`127177`)
|
||||||
|
* **Drogerien & Reform**:
|
||||||
|
* **dm-drogerie markt** (`127057`)
|
||||||
|
* **ROSSMANN** (`126811`)
|
||||||
|
* **Müller Drogeriemarkt** (`126720`)
|
||||||
|
|
||||||
|
Full details and ID mappings are documented in [`references/supermarkets.md`](references/supermarkets.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
The skill provides two primary components:
|
||||||
|
|
||||||
|
1. **CLI & Python Client (`scripts/marktguru_client.py`)**: Zero external dependencies (uses standard library `urllib` and `json`).
|
||||||
|
2. **MCP Server (`scripts/marktguru_mcp.py`)**: Standard JSON-RPC 2.0 stdio server providing AI tools for search, comparison, and catalog retrieval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CLI Usage
|
||||||
|
|
||||||
|
Run directly with standard `python3` (uses default postal code **`93059` Regensburg** if `--zip` is omitted):
|
||||||
|
|
||||||
|
### A. Search for Deals
|
||||||
|
```bash
|
||||||
|
# Search for butter in Regensburg (93059 default) across Aldi, Lidl, and Edeka
|
||||||
|
python3 scripts/marktguru_client.py search "Butter" --supermarket "Aldi,Lidl,Edeka"
|
||||||
|
|
||||||
|
# Search for coffee with raw JSON output
|
||||||
|
python3 scripts/marktguru_client.py search "Kaffee" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Price Comparison (Sorted Ascending)
|
||||||
|
```bash
|
||||||
|
# Compare chocolate prices across all supermarkets in 93059
|
||||||
|
python3 scripts/marktguru_client.py compare "Schokolade"
|
||||||
|
|
||||||
|
# Compare beer prices between Lidl and Rewe in another postal code (e.g. 80331)
|
||||||
|
python3 scripts/marktguru_client.py compare "Bier" --zip 80331 --supermarket "Lidl,Rewe"
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. List Supported Chains
|
||||||
|
```bash
|
||||||
|
python3 scripts/marktguru_client.py supermarkets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Model Context Protocol (MCP) Integration
|
||||||
|
|
||||||
|
The MCP server exposes three tools to any MCP-compatible AI agent:
|
||||||
|
|
||||||
|
* `search_supermarket_offers(query, zip_code="93059", supermarkets=None, limit=20)`
|
||||||
|
* `compare_supermarket_prices(query, zip_code="93059", supermarkets=None)`
|
||||||
|
* `list_supported_supermarkets()`
|
||||||
|
|
||||||
|
### MCP Server Configuration (`mcp_config.json`)
|
||||||
|
|
||||||
|
To enable the server in Antigravity or other MCP hosts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"marktguru": {
|
||||||
|
"command": "python3",
|
||||||
|
"args": [
|
||||||
|
"/Users/moritz/src/infrapuzzle/.agents/skills/marktguru/scripts/marktguru_mcp.py"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Python Integration
|
||||||
|
|
||||||
|
You can import the module directly in Python scripts or agent sidecars:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.append("/Users/moritz/src/infrapuzzle/.agents/skills/marktguru/scripts")
|
||||||
|
from marktguru_client import search_offers, compare_prices, DEFAULT_ZIP_CODE
|
||||||
|
|
||||||
|
# Search deals (defaults to 93059)
|
||||||
|
results = search_offers("Kaffee", supermarkets=["Aldi", "Lidl"])
|
||||||
|
for offer in results["offers"]:
|
||||||
|
print(f"[{offer['supermarket']}] {offer['product_name'] or offer['description']}: {offer['price']} €")
|
||||||
|
|
||||||
|
# Compare prices
|
||||||
|
comparison = compare_prices("Milch")
|
||||||
|
cheapest = comparison["cheapest_offer"]
|
||||||
|
print(f"Cheapest milk: {cheapest['price']} € at {cheapest['supermarket']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Extracted Per Offer
|
||||||
|
|
||||||
|
Each item returned from the Marktguru API includes:
|
||||||
|
* `id`: Internal Marktguru offer ID.
|
||||||
|
* `supermarket`: Retailer name (e.g. "ALDI SÜD", "Lidl", "EDEKA").
|
||||||
|
* `product_name`: Generic product name (e.g. "Butter", "Filterkaffee").
|
||||||
|
* `brand`: Brand name (e.g. "Kerrygold", "Melitta", "Milka").
|
||||||
|
* `description`: Promotional description / pack size (e.g. "je 250g Packung").
|
||||||
|
* `price`: Promotional sale price in Euros (€).
|
||||||
|
* `old_price`: Regular price or crossed-out price (UVP).
|
||||||
|
* `discount_percent`: Calculated percentage savings (e.g. 28%).
|
||||||
|
* `reference_price`: Unit price (e.g. 5.16 € / kg).
|
||||||
|
* `loyalty_app_required`: Boolean flag indicating if a digital store app (Lidl Plus, Edeka App, Penny App) is required to receive the discount.
|
||||||
|
* `valid_from` & `valid_to`: Start and end dates of the promotion.
|
||||||
|
* `image_url`: High-resolution product image hosted on CDN.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Unterstützte Supermärkte & Händler (Marktguru)
|
||||||
|
|
||||||
|
Über die Marktguru-Schnittstelle können Werbeprospekte, Wochenangebote und Rabattaktionen von über 450 Handelsketten und Einzelhändlern in Deutschland abgefragt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Discounter
|
||||||
|
|
||||||
|
Discounter machen einen Großteil der wöchentlich wechselnden Prospektaktionen aus. Marktguru unterscheidet sauber zwischen den Ketten:
|
||||||
|
|
||||||
|
| Kette | Marktguru ID | Typischer Aktionsrhythmus | App-Vorteile erfasst? |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **ALDI SÜD** | `127153` | Mo. & Do. / Fr. & Sa. | Ja (ALDI App Angebote) |
|
||||||
|
| **ALDI NORD** | `127139` | Mo. & Do. / Fr. & Sa. | Ja |
|
||||||
|
| **Lidl** | `126679` | Mo.–Sa. / Do.–Sa. / Fr.–Sa. | Ja (`loyalty_app_required` für Lidl Plus) |
|
||||||
|
| **PENNY** | `126765` | Mo.–Sa. / Do.–Sa. | Ja (Penny App / Payback) |
|
||||||
|
| **Netto Marken-Discount** | `126735` | Mo.–Sa. / Do.–Sa. | Ja (Netto App / DeutschlandCard) |
|
||||||
|
| **Netto (mit Hund)** | `126734` | Mo.–Sa. (primär Ost-/Norddeutschland) | Ja |
|
||||||
|
| **NORMA** | `126745` | Mo. & Mi. / Fr. & Sa. | Ja |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Vollsortimenter & Supermärkte
|
||||||
|
|
||||||
|
Hier gibt es oft filialgenaue oder regionale Prospekte. Die API filtert automatisch anhand der übergebenen Postleitzahl (PLZ):
|
||||||
|
|
||||||
|
| Kette | Marktguru ID | Besonderheiten |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **EDEKA** | `127093` | Nationale & regionale Angebote; Marktguru aggregiert auch selbstständige Kaufleute (z.B. EDEKA Simmel, Zurheide, Niemerszein). |
|
||||||
|
| **E center** | `126699` | Großformatige EDEKA-Märkte mit erweitertem Non-Food- und Frischeangebot. |
|
||||||
|
| **REWE** | `126802` | Bundesweite Angebote sowie regionale Aktionen. |
|
||||||
|
| **REWE Center** | `126804` | Großflächenmärkte von REWE. |
|
||||||
|
| **nahkauf** | `137320` | Nachbarschaftsmärkte der REWE Group. |
|
||||||
|
| **HIT** | `126598` | Große Frische-Supermärkte (Dohle Handelsgruppe). |
|
||||||
|
| **tegut** | `126866` | Fokus auf Bio, Nachhaltigkeit und Regionales (Hessen, Bayern, Thüringen). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Großfläche & SB-Warenhäuser (Hypermarkets)
|
||||||
|
|
||||||
|
| Kette | Marktguru ID | Besonderheiten |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Kaufland** | `126654` | Riesiges wöchentliches Prospektangebot (Lebensmittel + Non-Food, Kaufland Card). |
|
||||||
|
| **Globus** | `127177` | SB-Warenhäuser mit eigener Metzgerei und Bäckerei. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Drogeriemärkte
|
||||||
|
|
||||||
|
Drogerieprospekte enthalten neben Pflege- und Haushaltsartikeln auch viele Bio-Lebensmittel, Snacks, Kaffee und Babynahrung:
|
||||||
|
|
||||||
|
| Kette | Marktguru ID | Typische Angebote |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **dm-drogerie markt** | `127057` | dmBio, Drogeriewaren, Tiernahrung. |
|
||||||
|
| **ROSSMANN** | `126811` | EnerBio, Kaffeeangebote, wöchentliche 10%-Coupons. |
|
||||||
|
| **Müller Drogeriemarkt** | `126720` | Drogerie, Parfümerie, Spielwaren, Naturshop. |
|
||||||
|
| **budni** | `126944` | Primär im Norden (Metropolregion Hamburg). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Getränkemärkte & Bio-Fachmärkte
|
||||||
|
|
||||||
|
| Kette | Marktguru ID | Sortiment |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Netto Getränke-Discount** | `134009` | Bier, Mineralwasser, Softdrinks. |
|
||||||
|
| **Denns BioMarkt / Alnatura** | Diverse | Bio-Vollsortiment, Wochenaktionen. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Unterstützte Aliase im Client & MCP Server
|
||||||
|
|
||||||
|
Wenn im Tool-Aufruf der Parameter `supermarkets` übergeben wird, werden folgende umgangssprachliche Bezeichnungen automatisch auf die korrekten Retailer-IDs gemappt:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aldi": [127153, 127139], // Findet automatisch ALDI SÜD und ALDI NORD
|
||||||
|
"aldi süd" / "aldi sued": [127153],
|
||||||
|
"aldi nord": [127139],
|
||||||
|
"lidl": [126679],
|
||||||
|
"edeka": [127093, 126699], // EDEKA und E center
|
||||||
|
"rewe": [126802, 126804], // REWE und REWE Center
|
||||||
|
"kaufland": [126654],
|
||||||
|
"penny": [126765],
|
||||||
|
"netto": [126735, 126734], // Netto Marken-Discount und Netto (mit Hund)
|
||||||
|
"norma": [126745],
|
||||||
|
"globus": [127177],
|
||||||
|
"dm": [127057],
|
||||||
|
"rossmann": [126811],
|
||||||
|
"müller" / "mueller": [126720],
|
||||||
|
"hit": [126598],
|
||||||
|
"tegut": [126866],
|
||||||
|
"nahkauf": [137320]
|
||||||
|
}
|
||||||
|
```
|
||||||
Binary file not shown.
Binary file not shown.
+577
@@ -0,0 +1,577 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Marktguru API Client (Production Grade)
|
||||||
|
Fetches promotional offers and discounts from German supermarkets via the Marktguru API.
|
||||||
|
|
||||||
|
Key Design Principles:
|
||||||
|
- Zero external dependencies (uses standard library only: urllib, json, os, time, datetime).
|
||||||
|
- Resilient: Retries with exponential backoff on transient errors.
|
||||||
|
- Low Maintenance: Configurable keys and postal codes via environment variables.
|
||||||
|
- Fail-Safe: Caching with stale fallback if upstream API is temporarily unreachable.
|
||||||
|
- Defensive: Robust parsing protecting against upstream schema shifts and malformed payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||||
|
|
||||||
|
# Logger writing to stderr to keep stdout pure for JSON / CLI
|
||||||
|
logger = logging.getLogger("marktguru_client")
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
API_BASE = "https://api.marktguru.de/api/v1"
|
||||||
|
|
||||||
|
# Environment variable overrides for zero-code configuration changes
|
||||||
|
DEFAULT_ZIP_CODE = os.environ.get("MARKTGURU_ZIP_CODE", "93059")
|
||||||
|
CLIENT_KEY = os.environ.get("MARKTGURU_CLIENT_KEY", "WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90=")
|
||||||
|
API_KEY = os.environ.get("MARKTGURU_API_KEY", "8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=")
|
||||||
|
CACHE_TTL = int(os.environ.get("MARKTGURU_CACHE_TTL", "1800")) # 30 minutes
|
||||||
|
CACHE_ENABLED = os.environ.get("MARKTGURU_CACHE_ENABLED", "true").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"x-clientkey": CLIENT_KEY,
|
||||||
|
"x-apikey": API_KEY,
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Major supermarket chains and their Marktguru retailer IDs
|
||||||
|
MAJOR_RETAILERS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"ALDI SÜD": {"id": 127153, "type": "Discounter", "major": True},
|
||||||
|
"ALDI NORD": {"id": 127139, "type": "Discounter", "major": True},
|
||||||
|
"Lidl": {"id": 126679, "type": "Discounter", "major": True},
|
||||||
|
"EDEKA": {"id": 127093, "type": "Supermarkt", "major": True},
|
||||||
|
"E center": {"id": 126699, "type": "Supermarkt", "major": True},
|
||||||
|
"REWE": {"id": 126802, "type": "Supermarkt", "major": True},
|
||||||
|
"REWE Center": {"id": 126804, "type": "Supermarkt", "major": True},
|
||||||
|
"Kaufland": {"id": 126654, "type": "Großfläche/Hypermarkt", "major": True},
|
||||||
|
"PENNY": {"id": 126765, "type": "Discounter", "major": True},
|
||||||
|
"Netto Marken-Discount": {"id": 126735, "type": "Discounter", "major": True},
|
||||||
|
"Netto (mit Hund)": {"id": 126734, "type": "Discounter", "major": False},
|
||||||
|
"NORMA": {"id": 126745, "type": "Discounter", "major": True},
|
||||||
|
"Globus": {"id": 127177, "type": "Großfläche/Hypermarkt", "major": True},
|
||||||
|
"HIT": {"id": 126598, "type": "Supermarkt", "major": False},
|
||||||
|
"tegut": {"id": 126866, "type": "Supermarkt", "major": False},
|
||||||
|
"nahkauf": {"id": 137320, "type": "Supermarkt", "major": False},
|
||||||
|
"dm-drogerie markt": {"id": 127057, "type": "Drogerie", "major": True},
|
||||||
|
"ROSSMANN": {"id": 126811, "type": "Drogerie", "major": True},
|
||||||
|
"Müller Drogeriemarkt": {"id": 126720, "type": "Drogerie", "major": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aliases mapping user query strings to Marktguru retailer IDs
|
||||||
|
RETAILER_ALIASES: Dict[str, List[int]] = {
|
||||||
|
"aldi": [127153, 127139],
|
||||||
|
"aldi süd": [127153],
|
||||||
|
"aldi sued": [127153],
|
||||||
|
"aldi nord": [127139],
|
||||||
|
"lidl": [126679],
|
||||||
|
"edeka": [127093, 126699],
|
||||||
|
"e center": [126699],
|
||||||
|
"ecenter": [126699],
|
||||||
|
"rewe": [126802, 126804],
|
||||||
|
"kaufland": [126654],
|
||||||
|
"penny": [126765],
|
||||||
|
"netto": [126735, 126734],
|
||||||
|
"norma": [126745],
|
||||||
|
"globus": [127177],
|
||||||
|
"hit": [126598],
|
||||||
|
"tegut": [126866],
|
||||||
|
"nahkauf": [137320],
|
||||||
|
"dm": [127057],
|
||||||
|
"rossmann": [126811],
|
||||||
|
"müller": [126720],
|
||||||
|
"mueller": [126720],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cache_dir() -> str:
|
||||||
|
"""Returns directory path for local cached responses."""
|
||||||
|
cache_dir = os.path.join(tempfile.gettempdir(), "marktguru_cache")
|
||||||
|
try:
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cache_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache(cache_key: str, max_age_seconds: int = CACHE_TTL) -> Tuple[Optional[Dict[str, Any]], bool]:
|
||||||
|
"""
|
||||||
|
Reads cache file. Returns (data, is_fresh).
|
||||||
|
If cache is expired, returns (data, False) so caller can use it as emergency fallback.
|
||||||
|
"""
|
||||||
|
if not CACHE_ENABLED:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json")
|
||||||
|
if not os.path.isfile(cache_file):
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
payload = json.load(f)
|
||||||
|
cached_time = payload.get("_cached_at", 0)
|
||||||
|
age = time.time() - cached_time
|
||||||
|
data = payload.get("data")
|
||||||
|
is_fresh = age < max_age_seconds
|
||||||
|
return data, is_fresh
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to read cache {cache_key}: {e}")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache(cache_key: str, data: Dict[str, Any]) -> None:
|
||||||
|
"""Writes data to cache file atomically."""
|
||||||
|
if not CACHE_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json")
|
||||||
|
temp_file = f"{cache_file}.tmp.{os.getpid()}"
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"_cached_at": time.time(),
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
with open(temp_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, ensure_ascii=False)
|
||||||
|
os.replace(temp_file, cache_file)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to write cache {cache_key}: {e}")
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
try:
|
||||||
|
os.remove(temp_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_supermarket_ids(supermarkets: Optional[List[str]]) -> Optional[str]:
|
||||||
|
"""Resolves human supermarket names/aliases to comma-separated Marktguru retailer IDs."""
|
||||||
|
if not supermarkets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle string passed instead of list (e.g. "Aldi, Lidl")
|
||||||
|
if isinstance(supermarkets, str):
|
||||||
|
supermarkets = [s.strip() for s in supermarkets.split(",") if s.strip()]
|
||||||
|
|
||||||
|
resolved_ids: Set[int] = set()
|
||||||
|
for name in supermarkets:
|
||||||
|
if not isinstance(name, str):
|
||||||
|
continue
|
||||||
|
cleaned = name.strip().lower()
|
||||||
|
if not cleaned:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cleaned in RETAILER_ALIASES:
|
||||||
|
resolved_ids.update(RETAILER_ALIASES[cleaned])
|
||||||
|
elif cleaned.isdigit():
|
||||||
|
resolved_ids.add(int(cleaned))
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
for r_name, r_info in MAJOR_RETAILERS.items():
|
||||||
|
if cleaned in r_name.lower():
|
||||||
|
resolved_ids.add(r_info["id"])
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
logger.warning(f"Unknown supermarket name '{name}' skipped.")
|
||||||
|
|
||||||
|
return ",".join(str(i) for i in sorted(resolved_ids)) if resolved_ids else None
|
||||||
|
|
||||||
|
|
||||||
|
def format_iso_date(iso_str: Optional[str]) -> str:
|
||||||
|
"""Formats an ISO-8601 date string to human-readable DD.MM.YYYY format."""
|
||||||
|
if not iso_str or not isinstance(iso_str, str):
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%d.%m.%Y")
|
||||||
|
except Exception:
|
||||||
|
return iso_str[:10] if len(iso_str) >= 10 else iso_str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_offer(raw: Any, zip_code: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Defensively parses and standardizes an offer payload from Marktguru.
|
||||||
|
Protects against null fields, malformed dates, and missing keys.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
offer_id = raw.get("id")
|
||||||
|
price = raw.get("price")
|
||||||
|
if isinstance(price, (int, float)):
|
||||||
|
price = round(float(price), 2)
|
||||||
|
else:
|
||||||
|
price = None
|
||||||
|
|
||||||
|
old_price = raw.get("oldPrice")
|
||||||
|
if isinstance(old_price, (int, float)):
|
||||||
|
old_price = round(float(old_price), 2)
|
||||||
|
else:
|
||||||
|
old_price = None
|
||||||
|
|
||||||
|
ref_price = raw.get("referencePrice")
|
||||||
|
if isinstance(ref_price, (int, float)):
|
||||||
|
ref_price = round(float(ref_price), 2)
|
||||||
|
else:
|
||||||
|
ref_price = None
|
||||||
|
|
||||||
|
discount_pct = None
|
||||||
|
if old_price and price and old_price > price:
|
||||||
|
discount_pct = round(((old_price - price) / old_price) * 100)
|
||||||
|
|
||||||
|
# Validity dates
|
||||||
|
validity = raw.get("validityDates")
|
||||||
|
valid_from_raw = None
|
||||||
|
valid_to_raw = None
|
||||||
|
is_valid_now = True
|
||||||
|
days_remaining = None
|
||||||
|
|
||||||
|
if isinstance(validity, list) and len(validity) > 0 and isinstance(validity[0], dict):
|
||||||
|
valid_from_raw = validity[0].get("from")
|
||||||
|
valid_to_raw = validity[0].get("to")
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if valid_to_raw and isinstance(valid_to_raw, str):
|
||||||
|
dt_to = datetime.fromisoformat(valid_to_raw.replace("Z", "+00:00"))
|
||||||
|
diff = (dt_to - now).days
|
||||||
|
days_remaining = max(0, diff)
|
||||||
|
if dt_to < now:
|
||||||
|
is_valid_now = False
|
||||||
|
|
||||||
|
if valid_from_raw and isinstance(valid_from_raw, str):
|
||||||
|
dt_from = datetime.fromisoformat(valid_from_raw.replace("Z", "+00:00"))
|
||||||
|
if dt_from > now:
|
||||||
|
is_valid_now = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Supermarket / advertiser extraction
|
||||||
|
supermarket = "Unbekannt"
|
||||||
|
supermarket_id = None
|
||||||
|
advertisers = raw.get("advertisers")
|
||||||
|
if isinstance(advertisers, list) and len(advertisers) > 0 and isinstance(advertisers[0], dict):
|
||||||
|
supermarket = advertisers[0].get("name") or "Unbekannt"
|
||||||
|
supermarket_id = advertisers[0].get("id")
|
||||||
|
|
||||||
|
# High resolution CDN image
|
||||||
|
image_url = None
|
||||||
|
if offer_id:
|
||||||
|
image_url = f"https://mg2de.b-cdn.net/api/v1/offers/{offer_id}/images/default/0/medium.webp"
|
||||||
|
|
||||||
|
# Unit & volume
|
||||||
|
unit_info = raw.get("unit")
|
||||||
|
unit_name = None
|
||||||
|
if isinstance(unit_info, dict):
|
||||||
|
unit_name = unit_info.get("shortName") or unit_info.get("name")
|
||||||
|
|
||||||
|
product_info = raw.get("product")
|
||||||
|
product_name = None
|
||||||
|
if isinstance(product_info, dict):
|
||||||
|
product_name = product_info.get("name")
|
||||||
|
|
||||||
|
brand_info = raw.get("brand")
|
||||||
|
brand_name = None
|
||||||
|
if isinstance(brand_info, dict):
|
||||||
|
brand_name = brand_info.get("name")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": offer_id,
|
||||||
|
"supermarket": supermarket,
|
||||||
|
"supermarket_id": supermarket_id,
|
||||||
|
"product_name": product_name,
|
||||||
|
"brand": brand_name,
|
||||||
|
"description": raw.get("description"),
|
||||||
|
"price": price,
|
||||||
|
"old_price": old_price,
|
||||||
|
"reference_price": ref_price,
|
||||||
|
"discount_percent": discount_pct,
|
||||||
|
"unit": unit_name,
|
||||||
|
"volume": raw.get("volume"),
|
||||||
|
"loyalty_app_required": bool(raw.get("requiresLoyalityMembership", False)),
|
||||||
|
"is_valid_now": is_valid_now,
|
||||||
|
"days_remaining": days_remaining,
|
||||||
|
"valid_from": format_iso_date(valid_from_raw),
|
||||||
|
"valid_to": format_iso_date(valid_to_raw),
|
||||||
|
"image_url": image_url,
|
||||||
|
"zip_code": str(zip_code),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing single offer {raw.get('id') if isinstance(raw, dict) else raw}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_with_retry(url: str, max_retries: int = 2) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Executes HTTP request with exponential backoff on transient errors (5xx / network blips).
|
||||||
|
"""
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
req = urllib.request.Request(url, headers=HEADERS)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
raw_bytes = resp.read()
|
||||||
|
return json.loads(raw_bytes.decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
# Retry only on transient server errors (500, 502, 503, 504) or rate limits (429)
|
||||||
|
if e.code in (429, 500, 502, 503, 504) and attempt < max_retries:
|
||||||
|
backoff = 0.5 * (2 ** attempt)
|
||||||
|
logger.warning(f"Marktguru HTTP {e.code}. Retrying in {backoff:.1f}s (attempt {attempt+1}/{max_retries})...")
|
||||||
|
time.sleep(backoff)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < max_retries:
|
||||||
|
backoff = 0.5 * (2 ** attempt)
|
||||||
|
logger.warning(f"Connection issue ({e}). Retrying in {backoff:.1f}s...")
|
||||||
|
time.sleep(backoff)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise last_err or RuntimeError("Max retries exceeded")
|
||||||
|
|
||||||
|
|
||||||
|
def search_offers(
|
||||||
|
query: str,
|
||||||
|
zip_code: str = DEFAULT_ZIP_CODE,
|
||||||
|
supermarkets: Optional[List[str]] = None,
|
||||||
|
limit: int = 24,
|
||||||
|
offset: int = 0,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Searches current supermarket offers in Germany via the Marktguru API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch')
|
||||||
|
zip_code: German postal code (default: '93059' Regensburg)
|
||||||
|
supermarkets: List of supermarket names or aliases (e.g. ['Aldi', 'Lidl', 'Edeka'])
|
||||||
|
limit: Max number of items to return (default: 24, max: 50)
|
||||||
|
offset: Result pagination offset
|
||||||
|
force_refresh: If True, bypasses local TTL cache
|
||||||
|
"""
|
||||||
|
cleaned_query = (query or "").strip()
|
||||||
|
cleaned_zip = str(zip_code or DEFAULT_ZIP_CODE).strip()
|
||||||
|
limit = max(1, min(int(limit), 50))
|
||||||
|
offset = max(0, int(offset))
|
||||||
|
|
||||||
|
retailer_ids = resolve_supermarket_ids(supermarkets)
|
||||||
|
|
||||||
|
# Compute deterministic cache key
|
||||||
|
cache_raw = f"{cleaned_query.lower()}:{cleaned_zip}:{retailer_ids or ''}:{limit}:{offset}"
|
||||||
|
cache_key = hashlib.sha256(cache_raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
if not force_refresh:
|
||||||
|
cached_data, is_fresh = _read_cache(cache_key)
|
||||||
|
if is_fresh and cached_data:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"as": "web",
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"q": cleaned_query,
|
||||||
|
"zipCode": cleaned_zip,
|
||||||
|
}
|
||||||
|
if retailer_ids:
|
||||||
|
params["retailers"] = retailer_ids
|
||||||
|
|
||||||
|
url = f"{API_BASE}/offers/search?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _fetch_with_retry(url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Marktguru API query failed: {e}")
|
||||||
|
# Emergency fail-safe: Check if stale cache is available
|
||||||
|
cached_data, _ = _read_cache(cache_key, max_age_seconds=86400 * 7)
|
||||||
|
if cached_data:
|
||||||
|
logger.warning("Serving stale cached data due to API failure.")
|
||||||
|
cached_data["warning"] = "Stale cache returned due to temporary upstream API issue."
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
return {
|
||||||
|
"error": f"API request failed: {str(e)}",
|
||||||
|
"query": cleaned_query,
|
||||||
|
"zip_code": cleaned_zip,
|
||||||
|
"total_results": 0,
|
||||||
|
"returned_count": 0,
|
||||||
|
"offers": [],
|
||||||
|
"available_retailers": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_results = data.get("results") or []
|
||||||
|
parsed_offers: List[Dict[str, Any]] = []
|
||||||
|
for item in raw_results:
|
||||||
|
parsed = parse_offer(item, cleaned_zip)
|
||||||
|
if parsed is not None:
|
||||||
|
parsed_offers.append(parsed)
|
||||||
|
|
||||||
|
filters = data.get("filters") or {}
|
||||||
|
raw_retailers = filters.get("retailers") or []
|
||||||
|
available_retailers = [
|
||||||
|
{"name": r.get("name"), "id": r.get("id"), "count": r.get("resultsCount")}
|
||||||
|
for r in raw_retailers if isinstance(r, dict) and r.get("name")
|
||||||
|
]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"query": cleaned_query,
|
||||||
|
"zip_code": cleaned_zip,
|
||||||
|
"total_results": data.get("totalResults", len(parsed_offers)),
|
||||||
|
"returned_count": len(parsed_offers),
|
||||||
|
"available_retailers": available_retailers,
|
||||||
|
"offers": parsed_offers,
|
||||||
|
}
|
||||||
|
|
||||||
|
_write_cache(cache_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compare_prices(
|
||||||
|
query: str,
|
||||||
|
zip_code: str = DEFAULT_ZIP_CODE,
|
||||||
|
supermarkets: Optional[List[str]] = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Compares prices for a given product query across supermarkets, sorted from cheapest to most expensive.
|
||||||
|
"""
|
||||||
|
data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=50, force_refresh=force_refresh)
|
||||||
|
offers = data.get("offers", [])
|
||||||
|
|
||||||
|
# Filter out items without price and sort ascending
|
||||||
|
priced_offers = [o for o in offers if o.get("price") is not None]
|
||||||
|
priced_offers.sort(key=lambda x: x["price"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"zip_code": str(zip_code),
|
||||||
|
"total_compared": len(priced_offers),
|
||||||
|
"cheapest_offer": priced_offers[0] if priced_offers else None,
|
||||||
|
"all_offers_sorted": priced_offers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_supported_supermarkets() -> Dict[str, Any]:
|
||||||
|
"""Returns a structured catalog of all major supermarket chains supported by Marktguru."""
|
||||||
|
categories: Dict[str, List[Dict[str, Any]]] = {
|
||||||
|
"Discounter": [],
|
||||||
|
"Supermarkt": [],
|
||||||
|
"Großfläche / Hypermarkt": [],
|
||||||
|
"Drogerie": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, info in MAJOR_RETAILERS.items():
|
||||||
|
cat = info.get("type", "Sonstige")
|
||||||
|
if cat not in categories:
|
||||||
|
categories[cat] = []
|
||||||
|
categories[cat].append({
|
||||||
|
"name": name,
|
||||||
|
"id": info["id"],
|
||||||
|
"major": info.get("major", False)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"default_zip_code": DEFAULT_ZIP_CODE,
|
||||||
|
"cache_ttl_seconds": CACHE_TTL,
|
||||||
|
"supported_chains_count": len(MAJOR_RETAILERS),
|
||||||
|
"categories": categories,
|
||||||
|
"supported_aliases": list(RETAILER_ALIASES.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="Query German supermarket flyers/discounts via Marktguru")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_p = subparsers.add_parser("search", help="Search offers for a product")
|
||||||
|
search_p.add_argument("query", help="Product to search (e.g. Butter, Kaffee)")
|
||||||
|
search_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})")
|
||||||
|
search_p.add_argument("--supermarket", help="Comma-separated supermarkets (e.g. 'Aldi,Lidl,Edeka')")
|
||||||
|
search_p.add_argument("--limit", type=int, default=15, help="Max results (default 15)")
|
||||||
|
search_p.add_argument("--refresh", action="store_true", help="Bypass local cache")
|
||||||
|
search_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
comp_p = subparsers.add_parser("compare", help="Compare prices across supermarkets")
|
||||||
|
comp_p.add_argument("query", help="Product to compare")
|
||||||
|
comp_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})")
|
||||||
|
comp_p.add_argument("--supermarket", help="Comma-separated supermarkets")
|
||||||
|
comp_p.add_argument("--refresh", action="store_true", help="Bypass local cache")
|
||||||
|
comp_p.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||||
|
|
||||||
|
# Supermarkets
|
||||||
|
subparsers.add_parser("supermarkets", help="List all supported supermarkets and chains")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "supermarkets":
|
||||||
|
data = list_supported_supermarkets()
|
||||||
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
sm_list = [s.strip() for s in args.supermarket.split(",")] if getattr(args, "supermarket", None) else None
|
||||||
|
|
||||||
|
if args.command == "search":
|
||||||
|
result = search_offers(
|
||||||
|
args.query,
|
||||||
|
zip_code=args.zip,
|
||||||
|
supermarkets=sm_list,
|
||||||
|
limit=args.limit,
|
||||||
|
force_refresh=args.refresh
|
||||||
|
)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
if "error" in result and not result.get("offers"):
|
||||||
|
print(f"❌ Fehler: {result['error']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n🔍 Angebote für '{args.query}' in PLZ {args.zip} (Gefunden: {result['total_results']}):\n")
|
||||||
|
for o in result["offers"]:
|
||||||
|
disc_str = f" (-{o['discount_percent']}%)" if o.get("discount_percent") else ""
|
||||||
|
loyalty_str = " 📱 [App Coupon]" if o.get("loyalty_app_required") else ""
|
||||||
|
timing_str = f"noch {o['days_remaining']} Tage" if o.get("days_remaining") is not None else f"bis {o['valid_to']}"
|
||||||
|
print(f" • [{o['supermarket']}] {o['description'] or o['product_name'] or 'Angebot'}")
|
||||||
|
print(f" Preis: {o['price']:.2f} €{disc_str}{loyalty_str} | Gültig: {timing_str}")
|
||||||
|
if o.get("reference_price"):
|
||||||
|
print(f" Grundpreis: {o['reference_price']:.2f} € / {o.get('unit') or 'Einheit'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif args.command == "compare":
|
||||||
|
result = compare_prices(
|
||||||
|
args.query,
|
||||||
|
zip_code=args.zip,
|
||||||
|
supermarkets=sm_list,
|
||||||
|
force_refresh=args.refresh
|
||||||
|
)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(f"\n🏷️ Preisvergleich für '{args.query}' in PLZ {args.zip}:\n")
|
||||||
|
for idx, o in enumerate(result["all_offers_sorted"], 1):
|
||||||
|
disc_str = f" (UVP {o['old_price']:.2f}€)" if o.get("old_price") else ""
|
||||||
|
loyalty_str = " [App erforderlich]" if o.get("loyalty_app_required") else ""
|
||||||
|
print(f" {idx:2d}. {o['price']:.2f} € - [{o['supermarket']}] {o['description'] or o['product_name']}{disc_str}{loyalty_str}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Marktguru MCP (Model Context Protocol) Server (Production Grade)
|
||||||
|
Exposes German supermarket flyers, discounts, and price comparison tools over standard JSON-RPC 2.0 stdio.
|
||||||
|
|
||||||
|
Key Design Principles:
|
||||||
|
- Zero external dependencies: Uses standard library only.
|
||||||
|
- Strict Protocol Compliance: Conforms to MCP (2024-11-05) and JSON-RPC 2.0 specifications.
|
||||||
|
- Defensive Argument Coercion: Tolerates string/number variations commonly output by LLMs.
|
||||||
|
- Graceful Signal Handling: Clean exit on SIGINT, SIGTERM, and EOF.
|
||||||
|
- Stream Isolation: All logs and diagnostics route to stderr, ensuring stdout is 100% clean JSON-RPC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
# Import business logic from hardened client script
|
||||||
|
from marktguru_client import (
|
||||||
|
search_offers,
|
||||||
|
compare_prices,
|
||||||
|
list_supported_supermarkets,
|
||||||
|
DEFAULT_ZIP_CODE,
|
||||||
|
MAJOR_RETAILERS,
|
||||||
|
RETAILER_ALIASES,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure logging strictly to stderr
|
||||||
|
logging.basicConfig(
|
||||||
|
stream=sys.stderr,
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("marktguru-mcp")
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
SERVER_NAME = "marktguru-mcp-server"
|
||||||
|
SERVER_VERSION = "1.1.0"
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "search_supermarket_offers",
|
||||||
|
"description": (
|
||||||
|
"Search current weekly supermarket offers and promotional discounts in Germany via the Marktguru API. "
|
||||||
|
"Supports filtering by postal code (PLZ) and specific supermarket chains (e.g. Aldi Süd, Aldi Nord, Lidl, Edeka, Rewe, Kaufland, Penny, Netto)."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The product or keyword to search for (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch', 'Nutella', 'Schokolade')."
|
||||||
|
},
|
||||||
|
"zip_code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)."
|
||||||
|
},
|
||||||
|
"supermarkets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional list of supermarket names to filter by (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe', 'Kaufland', 'Penny', 'Netto', 'dm', 'Rossmann']). If omitted, all available retailers are included."
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of offers to return (default: 20, max: 50)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "compare_supermarket_prices",
|
||||||
|
"description": (
|
||||||
|
"Compare current prices for a product across German supermarkets in a given postal code. "
|
||||||
|
"Returns results sorted from lowest to highest price, highlighting the cheapest deal, regular prices (UVP), and required loyalty app memberships."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The product or grocery item to compare (e.g. 'Kaffee', 'Butter', 'Bier', 'Olivenöl')."
|
||||||
|
},
|
||||||
|
"zip_code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)."
|
||||||
|
},
|
||||||
|
"supermarkets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional list of supermarket names to compare (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe'])."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_supported_supermarkets",
|
||||||
|
"description": (
|
||||||
|
"List all supermarket, discounter, hypermarket, and drugstore chains supported by the Marktguru service in Germany, "
|
||||||
|
"including their categories (Discounter, Supermarkt, Großfläche, Drogerie) and accepted name aliases."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_supermarkets(sm_input: Any) -> Optional[List[str]]:
|
||||||
|
"""Coerces various LLM input formats (list, comma-string, single string) into a clean list."""
|
||||||
|
if not sm_input:
|
||||||
|
return None
|
||||||
|
if isinstance(sm_input, list):
|
||||||
|
return [str(s).strip() for s in sm_input if str(s).strip()]
|
||||||
|
if isinstance(sm_input, str):
|
||||||
|
return [s.strip() for s in sm_input.split(",") if s.strip()]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_initialize(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": PROTOCOL_VERSION,
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {
|
||||||
|
"listChanged": False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": SERVER_NAME,
|
||||||
|
"version": SERVER_VERSION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_tools_list(req_id: Any) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"tools": TOOLS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_tools_call(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
tool_name = params.get("name")
|
||||||
|
args = params.get("arguments", {})
|
||||||
|
|
||||||
|
logger.info(f"Tool call requested: {tool_name} with args={args}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if tool_name == "search_supermarket_offers":
|
||||||
|
query = str(args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip()
|
||||||
|
supermarkets = _sanitize_supermarkets(args.get("supermarkets"))
|
||||||
|
try:
|
||||||
|
limit = int(args.get("limit") or 20)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
limit = 20
|
||||||
|
|
||||||
|
data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=limit)
|
||||||
|
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
elif tool_name == "compare_supermarket_prices":
|
||||||
|
query = str(args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip()
|
||||||
|
supermarkets = _sanitize_supermarkets(args.get("supermarkets"))
|
||||||
|
|
||||||
|
data = compare_prices(query, zip_code=zip_code, supermarkets=supermarkets)
|
||||||
|
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
elif tool_name == "list_supported_supermarkets":
|
||||||
|
data = list_supported_supermarkets()
|
||||||
|
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"error": {
|
||||||
|
"code": -32601,
|
||||||
|
"message": f"Method or tool '{tool_name}' not found."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": text_result
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Exception during tool execution '{tool_name}': {e}")
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": f"Tool execution failed: {type(e).__name__}: {str(e)}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_message(line: str) -> Optional[Dict[str, Any]]:
|
||||||
|
if not line.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Malformed JSON-RPC received: {e}")
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": None,
|
||||||
|
"error": {"code": -32700, "message": f"Parse error: {str(e)}"}
|
||||||
|
}
|
||||||
|
|
||||||
|
method = msg.get("method")
|
||||||
|
req_id = msg.get("id")
|
||||||
|
params = msg.get("params", {})
|
||||||
|
|
||||||
|
# Notifications do not return a response
|
||||||
|
if req_id is None:
|
||||||
|
if method == "notifications/initialized":
|
||||||
|
logger.info("Client acknowledged initialized notification.")
|
||||||
|
elif method == "notifications/cancelled":
|
||||||
|
logger.info(f"Client cancelled operation: {params}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Standard Requests
|
||||||
|
if method == "initialize":
|
||||||
|
return handle_initialize(req_id, params)
|
||||||
|
elif method == "tools/list":
|
||||||
|
return handle_tools_list(req_id)
|
||||||
|
elif method == "tools/call":
|
||||||
|
return handle_tools_call(req_id, params)
|
||||||
|
elif method == "ping":
|
||||||
|
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"error": {
|
||||||
|
"code": -32601,
|
||||||
|
"message": f"Method '{method}' is not supported."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sig_handler(sig, frame):
|
||||||
|
logger.info(f"Received termination signal {sig}. Exiting gracefully.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
signal.signal(signal.SIGINT, _sig_handler)
|
||||||
|
signal.signal(signal.SIGTERM, _sig_handler)
|
||||||
|
|
||||||
|
logger.info(f"Starting {SERVER_NAME} v{SERVER_VERSION} (stdio mode)...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
for raw_line in sys.stdin:
|
||||||
|
response = process_message(raw_line)
|
||||||
|
if response is not None:
|
||||||
|
output = json.dumps(response, ensure_ascii=False) + "\n"
|
||||||
|
sys.stdout.write(output)
|
||||||
|
sys.stdout.flush()
|
||||||
|
except (KeyboardInterrupt, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Fatal error in stdio loop: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info("Marktguru MCP server shutdown complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Unit tests for the Marktguru skill & MCP Server.
|
||||||
|
Runs completely offline using standard library unittest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# Add scripts directory to path
|
||||||
|
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||||
|
if SCRIPTS_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SCRIPTS_DIR)
|
||||||
|
|
||||||
|
from marktguru_client import (
|
||||||
|
resolve_supermarket_ids,
|
||||||
|
format_iso_date,
|
||||||
|
parse_offer,
|
||||||
|
list_supported_supermarkets,
|
||||||
|
DEFAULT_ZIP_CODE,
|
||||||
|
_read_cache,
|
||||||
|
_write_cache,
|
||||||
|
_get_cache_dir,
|
||||||
|
)
|
||||||
|
from marktguru_mcp import process_message, SERVER_NAME, PROTOCOL_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarktguruClient(unittest.TestCase):
|
||||||
|
def test_default_zip_code(self):
|
||||||
|
self.assertEqual(DEFAULT_ZIP_CODE, "93059")
|
||||||
|
|
||||||
|
def test_resolve_supermarket_ids_aliases(self):
|
||||||
|
# Aldi maps to Aldi Nord and Aldi Süd
|
||||||
|
aldi_ids = resolve_supermarket_ids(["aldi"])
|
||||||
|
self.assertIn("127153", aldi_ids)
|
||||||
|
self.assertIn("127139", aldi_ids)
|
||||||
|
|
||||||
|
# Edeka maps to Edeka and E center
|
||||||
|
edeka_ids = resolve_supermarket_ids(["edeka"])
|
||||||
|
self.assertIn("127093", edeka_ids)
|
||||||
|
self.assertIn("126699", edeka_ids)
|
||||||
|
|
||||||
|
# Lidl
|
||||||
|
lidl_ids = resolve_supermarket_ids(["lidl"])
|
||||||
|
self.assertEqual(lidl_ids, "126679")
|
||||||
|
|
||||||
|
# Comma-separated string support
|
||||||
|
combined = resolve_supermarket_ids("Lidl, Kaufland")
|
||||||
|
self.assertIn("126679", combined)
|
||||||
|
self.assertIn("126654", combined)
|
||||||
|
|
||||||
|
# Unknown supermarket is ignored gracefully
|
||||||
|
unknown = resolve_supermarket_ids(["NonExistentMarket123"])
|
||||||
|
self.assertIsNone(unknown)
|
||||||
|
|
||||||
|
def test_format_iso_date(self):
|
||||||
|
self.assertEqual(format_iso_date("2026-10-15T12:00:00Z"), "15.10.2026")
|
||||||
|
self.assertEqual(format_iso_date(None), "-")
|
||||||
|
self.assertEqual(format_iso_date(""), "-")
|
||||||
|
self.assertEqual(format_iso_date("invalid-date-format"), "invalid-da")
|
||||||
|
|
||||||
|
def test_parse_offer_defensive(self):
|
||||||
|
# 1. Complete payload
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
from_date = (now - timedelta(days=1)).isoformat()
|
||||||
|
to_date = (now + timedelta(days=4)).isoformat()
|
||||||
|
|
||||||
|
raw_complete = {
|
||||||
|
"id": 99999,
|
||||||
|
"price": 1.49,
|
||||||
|
"oldPrice": 2.29,
|
||||||
|
"referencePrice": 5.96,
|
||||||
|
"requiresLoyalityMembership": True,
|
||||||
|
"description": "250g Packung",
|
||||||
|
"product": {"name": "Süßrahmbutter"},
|
||||||
|
"brand": {"name": "Landliebe"},
|
||||||
|
"advertisers": [{"name": "EDEKA", "id": "retailers/127093"}],
|
||||||
|
"unit": {"shortName": "kg"},
|
||||||
|
"volume": 0.25,
|
||||||
|
"validityDates": [{"from": from_date, "to": to_date}]
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed = parse_offer(raw_complete, "93059")
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["id"], 99999)
|
||||||
|
self.assertEqual(parsed["supermarket"], "EDEKA")
|
||||||
|
self.assertEqual(parsed["price"], 1.49)
|
||||||
|
self.assertEqual(parsed["old_price"], 2.29)
|
||||||
|
self.assertEqual(parsed["discount_percent"], 35) # (2.29 - 1.49) / 2.29 = 34.9% -> 35%
|
||||||
|
self.assertTrue(parsed["loyalty_app_required"])
|
||||||
|
self.assertTrue(parsed["is_valid_now"])
|
||||||
|
self.assertGreaterEqual(parsed["days_remaining"], 3)
|
||||||
|
self.assertIn("https://mg2de.b-cdn.net/api/v1/offers/99999", parsed["image_url"])
|
||||||
|
|
||||||
|
# 2. Minimal / empty payload
|
||||||
|
raw_empty = {}
|
||||||
|
parsed_min = parse_offer(raw_empty, "93059")
|
||||||
|
self.assertIsNotNone(parsed_min)
|
||||||
|
self.assertEqual(parsed_min["supermarket"], "Unbekannt")
|
||||||
|
self.assertIsNone(parsed_min["price"])
|
||||||
|
self.assertIsNone(parsed_min["discount_percent"])
|
||||||
|
|
||||||
|
# 3. Invalid types (not a dict)
|
||||||
|
self.assertIsNone(parse_offer("not a dict", "93059"))
|
||||||
|
self.assertIsNone(parse_offer(None, "93059"))
|
||||||
|
|
||||||
|
def test_cache_mechanism(self):
|
||||||
|
test_key = "test_unit_key_123"
|
||||||
|
test_data = {"hello": "world", "timestamp": 12345}
|
||||||
|
|
||||||
|
_write_cache(test_key, test_data)
|
||||||
|
data, is_fresh = _read_cache(test_key, max_age_seconds=60)
|
||||||
|
self.assertTrue(is_fresh)
|
||||||
|
self.assertEqual(data, test_data)
|
||||||
|
|
||||||
|
# Stale cache test
|
||||||
|
data_stale, is_fresh_stale = _read_cache(test_key, max_age_seconds=-1)
|
||||||
|
self.assertFalse(is_fresh_stale)
|
||||||
|
self.assertEqual(data_stale, test_data) # still returned as fallback!
|
||||||
|
|
||||||
|
def test_list_supported_supermarkets(self):
|
||||||
|
catalog = list_supported_supermarkets()
|
||||||
|
self.assertEqual(catalog["default_zip_code"], "93059")
|
||||||
|
self.assertIn("Discounter", catalog["categories"])
|
||||||
|
self.assertIn("Supermarkt", catalog["categories"])
|
||||||
|
self.assertIn("aldi", catalog["supported_aliases"])
|
||||||
|
self.assertIn("edeka", catalog["supported_aliases"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarktguruMCP(unittest.TestCase):
|
||||||
|
def test_mcp_initialize(self):
|
||||||
|
req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
|
||||||
|
resp = process_message(req)
|
||||||
|
self.assertEqual(resp["id"], 1)
|
||||||
|
self.assertEqual(resp["result"]["protocolVersion"], PROTOCOL_VERSION)
|
||||||
|
self.assertEqual(resp["result"]["serverInfo"]["name"], SERVER_NAME)
|
||||||
|
|
||||||
|
def test_mcp_tools_list(self):
|
||||||
|
req = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
||||||
|
resp = process_message(req)
|
||||||
|
tools = [t["name"] for t in resp["result"]["tools"]]
|
||||||
|
self.assertIn("search_supermarket_offers", tools)
|
||||||
|
self.assertIn("compare_supermarket_prices", tools)
|
||||||
|
self.assertIn("list_supported_supermarkets", tools)
|
||||||
|
|
||||||
|
def test_mcp_tools_call_supermarket_catalog(self):
|
||||||
|
req = json.dumps({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": "list_supported_supermarkets", "arguments": {}}
|
||||||
|
})
|
||||||
|
resp = process_message(req)
|
||||||
|
self.assertFalse(resp["result"]["isError"])
|
||||||
|
content_text = resp["result"]["content"][0]["text"]
|
||||||
|
data = json.loads(content_text)
|
||||||
|
self.assertEqual(data["default_zip_code"], "93059")
|
||||||
|
|
||||||
|
def test_mcp_tools_call_missing_query(self):
|
||||||
|
req = json.dumps({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 4,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": "search_supermarket_offers", "arguments": {"query": ""}}
|
||||||
|
})
|
||||||
|
resp = process_message(req)
|
||||||
|
self.assertTrue(resp["result"]["isError"])
|
||||||
|
self.assertIn("required", resp["result"]["content"][0]["text"])
|
||||||
|
|
||||||
|
def test_mcp_notification_silence(self):
|
||||||
|
req = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||||
|
resp = process_message(req)
|
||||||
|
self.assertIsNone(resp) # Notifications must not produce responses
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-ops
|
||||||
|
license: Apache-2.0
|
||||||
|
description: Diagnose, troubleshoot, compact, and recover OpenClaw multi-agent sessions, SQLite state, and Telegram topic channels. Use when investigating why a Telegram topic is silent or not answering, inspecting session nodes and transcript events in SQLite, recovering wedged or locked sessions, running multi-agent transcript compactions, checking and clearing database quarantines or lockfiles, pre-warming Linux page cache on mechanical HDDs, or sending out-of-band direct messages to Telegram.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw Operations & Troubleshooting Runbook
|
||||||
|
|
||||||
|
> **Target Environment**: Kubernetes cluster `haumdaucher`, namespace `openclaw`, pod `openclaw-0`.
|
||||||
|
> **Key References**: [`k8s/openclaw/AGENTS.md`](../../../k8s/openclaw/AGENTS.md) and [`k8s/openclaw/TROUBLESHOOTING.md`](../../../k8s/openclaw/TROUBLESHOOTING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Instant One-Shot Triage (< 2 Seconds)
|
||||||
|
|
||||||
|
Whenever investigating an issue in a Telegram forum topic or agent, **run the one-shot triage tool FIRST**.
|
||||||
|
Do NOT run piecemeal SQLite queries, guess column names, or scan the root filesystem.
|
||||||
|
|
||||||
|
### Primary Command:
|
||||||
|
```bash
|
||||||
|
# By Telegram forum topic number (e.g. 21=Kochen, 22=Mila, 117=Research)
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 /home/node/.openclaw/scripts/triage.py --topic <topicNumber>
|
||||||
|
|
||||||
|
# Or by agent name
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 /home/node/.openclaw/scripts/triage.py --agent <agentId>
|
||||||
|
```
|
||||||
|
|
||||||
|
The script automatically reports:
|
||||||
|
1. Ground-truth session status (`done`, `processing`, `running`) and token size.
|
||||||
|
2. Inbound user prompt (including speech-to-text audio transcripts).
|
||||||
|
3. Assistant execution trace: thinking excerpts, tool calls with arguments, tool errors (`❌`), and final text output.
|
||||||
|
4. Instant anomaly flags: `[CRITICAL_CONTEXT_BLOAT >60k]`, `[HIGH_CONTEXT_TOKENS >20k]`, `[POSSIBLY_WEDGED_RUNNING]`, `[UNANSWERED USER MESSAGE]`.
|
||||||
|
|
||||||
|
### 🚫 Anti-Patterns & Operational Guardrails:
|
||||||
|
- ❌ **NEVER run `find /` or broad `grep -r` on the root container filesystem.** Host `haumdaucher` uses spinning mechanical HDDs (`rotational: 1`). Root sweeps take 5+ minutes, freeze I/O, and trigger background tasks.
|
||||||
|
- ❌ **NEVER guess SQLite table or column names.**
|
||||||
|
- **`session_nodes`**: `(session_key, current_session_id, status, updated_at, entry_json)`
|
||||||
|
- **`transcript_events`**: `(session_id, seq, event_json, created_at)`
|
||||||
|
- 📍 **Known Path Anchors (Use Directly):**
|
||||||
|
- Knowledge Wiki: `/home/node/.openclaw/wiki/main`
|
||||||
|
- Agent SQLite DBs: `/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite`
|
||||||
|
- Gateway State DB: `/home/node/.openclaw/state/openclaw.sqlite`
|
||||||
|
- Agent Workspace: `/home/openclaw/.openclaw/workspace-<agent>`
|
||||||
|
- Standalone Triage Script: `/home/node/.openclaw/scripts/triage.py` (Repo: `k8s/openclaw/scripts/openclaw-triage.py`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Diagnostic Playbook: "Bot is Not Answering / Silent"
|
||||||
|
|
||||||
|
When a user reports that a Telegram forum topic or direct chat is unresponsive:
|
||||||
|
|
||||||
|
### ⚠️ Golden Rule: Do NOT Assume Container Crash
|
||||||
|
Long turns (multi-step tools, deep research, or inline compaction) can cause multi-minute delays while the container is perfectly healthy (`5/5 Running`).
|
||||||
|
|
||||||
|
### Step 1: Check Live Logs for In-Flight Turns
|
||||||
|
```bash
|
||||||
|
kubectl logs openclaw-0 -n openclaw -c openclaw --tail=100
|
||||||
|
```
|
||||||
|
Look for:
|
||||||
|
- `compact:start reason=auto-threshold`: Inline safeguard compaction in progress (takes 30–60s).
|
||||||
|
- `embedded run tool start ... tool=write|exec`: Agent is actively executing tool steps.
|
||||||
|
- `[model-fetch] start ... status=200`: Waiting on LLM token streaming.
|
||||||
|
- `heartbeat: webhooks=... active=X`: Active turn count in gateway.
|
||||||
|
|
||||||
|
### Step 2: Query Ground-Truth Session Status in SQLite
|
||||||
|
Each agent maintains its own SQLite database at `/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite`.
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import sqlite3, glob
|
||||||
|
for db in sorted(glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite')):
|
||||||
|
agent = db.split('/')[5]
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
rows = con.execute('SELECT session_key, status, active_writer_run_id, updated_at FROM session_nodes WHERE session_key LIKE \"%topic:20%\"').fetchall()
|
||||||
|
if rows:
|
||||||
|
print(f'[{agent}]', rows)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
- If `status == "processing"`: Turn is actively executing.
|
||||||
|
- If `status == "done"`: Turn completed. Check outbound Telegram dispatch.
|
||||||
|
- If `status == "running"` with old `updated_at`: Session is wedged (see Recovery below).
|
||||||
|
|
||||||
|
### Step 3: Inspect Recent Transcript Events
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import sqlite3, json
|
||||||
|
con = sqlite3.connect('/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite')
|
||||||
|
sid = con.execute('SELECT current_session_id FROM session_nodes WHERE session_key = ?', ('<sessionKey>',)).fetchone()[0]
|
||||||
|
for seq, ca, ej in con.execute('SELECT seq, created_at, substr(event_json, 1, 250) FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 5', (sid,)).fetchall():
|
||||||
|
print(seq, ca, ej)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Session Recovery & Lock Clearing
|
||||||
|
|
||||||
|
### Un-wedge a Stuck Session Node
|
||||||
|
If a turn was killed mid-execution (e.g. OOMKill or node reboot):
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
con = sqlite3.connect('/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite')
|
||||||
|
con.execute('UPDATE session_nodes SET status = \"done\", active_writer_run_id = NULL, entry_valid = 1 WHERE session_key = ?', ('<sessionKey>',))
|
||||||
|
con.commit()
|
||||||
|
print('Session un-wedged successfully.')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Stale State Locks
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- rm -f /tmp/openclaw-1000/gateway.state.lock /tmp/.openclaw-1000/gateway.state.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Foreign Key Violations & Clear Quarantine
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import sqlite3, glob
|
||||||
|
for db in glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite'):
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
fk = con.execute('PRAGMA foreign_key_check;').fetchall()
|
||||||
|
if fk:
|
||||||
|
print(f'FK violations in {db}:', fk)
|
||||||
|
"
|
||||||
|
# Clear quarantine table if present
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import sqlite3, os
|
||||||
|
p = '/home/node/.openclaw/state/openclaw-quarantine.sqlite'
|
||||||
|
if os.path.exists(p):
|
||||||
|
c = sqlite3.connect(p)
|
||||||
|
c.execute('DELETE FROM quarantined_databases')
|
||||||
|
c.commit()
|
||||||
|
print('Quarantine cleared.')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Multi-Agent Compaction & Transcript Bloat Control
|
||||||
|
|
||||||
|
### Pitfall: Stale Sessions Report `totalTokens: null`
|
||||||
|
`openclaw sessions --all-agents --json` returns `totalTokens: null` (`totalTokensFresh: false`) for sessions that haven't received a message in several days. **Always inspect SQLite directly**:
|
||||||
|
```bash
|
||||||
|
# Check size of active sessions across all agents
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import glob, sqlite3, json
|
||||||
|
for db in sorted(glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite')):
|
||||||
|
agent = db.split('/')[5]
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
for skey, sid, ej in con.execute('SELECT session_key, current_session_id, entry_json FROM session_nodes WHERE session_key LIKE \"%:telegram:%\"').fetchall():
|
||||||
|
cnt, bsz = con.execute('SELECT count(*), coalesce(sum(length(event_json)), 0) FROM transcript_events WHERE session_id=?', (sid,)).fetchone()
|
||||||
|
if bsz > 300000 or cnt > 40:
|
||||||
|
print(f'{agent:12} {bsz//1024:6} KB {cnt:4} events {skey}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manually Compact a Stale or Bloated Session
|
||||||
|
```bash
|
||||||
|
# LLM summarization with 3-minute timeout
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw sessions compact "<sessionKey>" --agent <agentId> --timeout 180000
|
||||||
|
|
||||||
|
# Truncation fallback (if LLM summarization times out)
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw sessions compact "<sessionKey>" --agent <agentId> --max-lines 80 --timeout 180000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ensure User Compaction Notification is Enabled
|
||||||
|
To prevent silent user-perceived freezes during daytime turns:
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw config set agents.defaults.compaction.notifyUser true --strict-json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mechanical HDD / Page Cache Pre-Warming
|
||||||
|
|
||||||
|
Node `haumdaucher` uses spinning HDDs (`rotational: 1`). Cold database opens take up to 20–50 minutes without page cache warming.
|
||||||
|
|
||||||
|
### Warm Page Cache Instantly (<1s)
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- /bin/sh -c \
|
||||||
|
"cat /home/node/.openclaw/state/*.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true"
|
||||||
|
```
|
||||||
|
|
||||||
|
### WAL Checkpoints & SQLite Optimization
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||||
|
import glob, sqlite3
|
||||||
|
dbs = ['/home/node/.openclaw/state/openclaw.sqlite'] + glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite')
|
||||||
|
for db in dbs:
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
con.execute('PRAGMA wal_checkpoint(TRUNCATE);')
|
||||||
|
con.execute('PRAGMA optimize;')
|
||||||
|
con.close()
|
||||||
|
print('WAL checkpoints and optimizations complete.')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Emergency Telegram Direct Message Bypass
|
||||||
|
|
||||||
|
If OpenClaw's turn engine failed to send a message or an urgent human notification is required without spawning a conversation turn:
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw message send \
|
||||||
|
--channel telegram \
|
||||||
|
--target "-1004326921510" \
|
||||||
|
--thread-id <topicNumber> \
|
||||||
|
--message "Dein Nachrichtentext hier..."
|
||||||
|
```
|
||||||
|
*(Common topics: Sport = 20, Kochen = 21, Mila = 22, Travel = 51, Shopping = 115, Regensburg = 116, Research = 117, Medizin = 118, IT Guy = 119, Media = 164).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Post-Troubleshooting Evolution Loop & Simplicity Invariant
|
||||||
|
|
||||||
|
At the end of **every single troubleshooting task, incident investigation, or bugfix**:
|
||||||
|
|
||||||
|
### 🔄 Mandatory Dual-Reflection
|
||||||
|
The AI agent MUST proactively evaluate and document two distinct vectors of improvement before concluding:
|
||||||
|
1. **Troubleshooting Process Optimization**:
|
||||||
|
- Did gathering information or isolating the root cause take too long?
|
||||||
|
- Did any query fail due to syntax, guessing, or slow filesystem sweeps?
|
||||||
|
- What new diagnostic fastpath, schema shortcut, or check should be codified into `openclaw-ops` or `triage.py` so the next diagnosis takes seconds?
|
||||||
|
2. **System Maturity & Long-Term Hardening**:
|
||||||
|
- What fragile assumption or missing guardrail allowed this error to surface?
|
||||||
|
- How can the prompt, schema, datastore script, or cronjob be hardened permanently so this class of error never recurs?
|
||||||
|
|
||||||
|
### ⚖️ The Simplicity Invariant (The 98% Rule)
|
||||||
|
- **Simplicity and clean, transparent architecture are the central design principles.**
|
||||||
|
- Always prefer a simple, pragmatic, easily debuggable solution that covers **98% of the goal** over an intricate, over-engineered abstraction that attempts 100%.
|
||||||
|
- Avoid speculative background tasks, complex event chains, or hidden state manipulations. Single-responsibility scripts, transparent alerts, and direct deterministic data flows always take precedence.
|
||||||
|
|
||||||
@@ -2,3 +2,10 @@ k8s/gitea-chart/
|
|||||||
.vagrant
|
.vagrant
|
||||||
k8s/tekton-pipelines/auth
|
k8s/tekton-pipelines/auth
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
dist/
|
||||||
|
**/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
|||||||
@@ -14,35 +14,28 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
|||||||
|
|
||||||
### 2. [Kubernetes Workloads](./k8s/AGENTS.md) (`k8s/`)
|
### 2. [Kubernetes Workloads](./k8s/AGENTS.md) (`k8s/`)
|
||||||
* **Purpose**: Deploying applications and services to the cluster.
|
* **Purpose**: Deploying applications and services to the cluster.
|
||||||
* **Method**: GitOps-style directory structure (Namespace = Folder).
|
* **Strict Namespace Rule**: **Every top-level subdirectory in `k8s/` strictly corresponds to a Kubernetes namespace.** Non-namespace folders, loose scripts, or documentation do not belong here.
|
||||||
* **Constraints**: Enforces `ingress-nginx`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
|
* **Constraints**: Enforces `ingress-nginx` / `traefik`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
|
||||||
|
* **OpenClaw**: For OpenClaw AI agent deployment and troubleshooting, see [k8s/openclaw/AGENTS.md](./k8s/openclaw/AGENTS.md) and [k8s/openclaw/TROUBLESHOOTING.md](./k8s/openclaw/TROUBLESHOOTING.md).
|
||||||
* **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md)
|
* **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md)
|
||||||
|
|
||||||
### 3. Terraform (`terraform/`)
|
### 3. [Terraform](./terraform/AGENTS.md) (`terraform/`)
|
||||||
* **Purpose**: Infrastructure as Code for external resources, primarily **DNS**.
|
* **Purpose**: Declarative core cloud infrastructure as code.
|
||||||
* **Provider**: OVH (`ovh/ovh`).
|
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others via OVH (`ovh/ovh`).
|
||||||
* **Scope**:
|
* **Cloud Services**: Authentik SSO (`authentik.tf`), GCP APIs, Velero backup SA (`gcp_backups.tf`), OpenClaw calendar SA (`gcp_calendar.tf`), and Google Workspace MX/SPF/DKIM (`google_workspace.tf`).
|
||||||
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others.
|
* **Usage**: `cd terraform && terraform apply`
|
||||||
* **Mail Config**: Google Workspace MX/SPF/DKIM records.
|
* **Read**: [terraform/AGENTS.md](./terraform/AGENTS.md)
|
||||||
* **Cluster IP**: Points domains to the Haumdaucher node IP (`136.243.23.215`).
|
|
||||||
* **Usage**:
|
|
||||||
```bash
|
|
||||||
cd terraform
|
|
||||||
terraform init
|
|
||||||
terraform apply
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Monitoring (`monitoring/`)
|
### 4. Monitoring (`monitoring/`)
|
||||||
* **Purpose**: IoT and physical device monitoring, separate from K8s metrics.
|
* **Purpose**: Physical ambient BLE sensor fleet monitoring across living areas.
|
||||||
* **Tool**: [ESPHome](https://esphome.io).
|
* **Tool**: [ESPHome](https://esphome.io).
|
||||||
* **Devices**: Temperature sensors, plant monitors (Xiaomi/BLE), ESP32 bridges.
|
* **Devices**: Temperature & humidity sensors, Xiaomi BLE plants, ESP32 bridges (`muc-kitchen`, `muc-living`, `rgbg-living`).
|
||||||
* **Usage**:
|
* **Usage**:
|
||||||
```bash
|
```bash
|
||||||
cd monitoring
|
cd monitoring
|
||||||
# Flash or update a device
|
|
||||||
esphome run config/<device>.secret.yml
|
esphome run config/<device>.secret.yml
|
||||||
```
|
```
|
||||||
* **Network**: Devices typically operate on static IPs in the `192.168.x.x` range.
|
* **Read**: [monitoring/README.md](./monitoring/README.md)
|
||||||
|
|
||||||
### 5. Knowledge Wiki & OpenClaw Tasks (`knowledge-wiki/`)
|
### 5. Knowledge Wiki & OpenClaw Tasks (`knowledge-wiki/`)
|
||||||
* **Purpose**: Shared personal knowledge wiki and task dispatch between OpenClaw (mobile/Telegram concierge) and Antigravity (local pair programming agent).
|
* **Purpose**: Shared personal knowledge wiki and task dispatch between OpenClaw (mobile/Telegram concierge) and Antigravity (local pair programming agent).
|
||||||
@@ -55,7 +48,7 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
|||||||
3. Implement the chosen task in the appropriate repository files.
|
3. Implement the chosen task in the appropriate repository files.
|
||||||
4. Move the task to `## ✅ Completed Tasks` with completion details and commit info.
|
4. Move the task to `## ✅ Completed Tasks` with completion details and commit info.
|
||||||
5. Push back: `git -C knowledge-wiki commit -am "chore(tasks): complete <task>" && git -C knowledge-wiki push origin main`.
|
5. Push back: `git -C knowledge-wiki commit -am "chore(tasks): complete <task>" && git -C knowledge-wiki push origin main`.
|
||||||
- When Antigravity needs knowledge about family, home, servers, or setup:
|
|
||||||
### 6. Architecture Decision Records (`docs/adr/`)
|
### 6. Architecture Decision Records (`docs/adr/`)
|
||||||
* **Purpose**: Formal record of architectural decisions, technical trade-offs, and design standards across the monorepo.
|
* **Purpose**: Formal record of architectural decisions, technical trade-offs, and design standards across the monorepo.
|
||||||
* **Format**: [MADR](https://adr.github.io/madr/) (Markdown Architectural Decision Records).
|
* **Format**: [MADR](https://adr.github.io/madr/) (Markdown Architectural Decision Records).
|
||||||
@@ -65,6 +58,72 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
|||||||
* [ADR 0002: OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics](docs/adr/0002-openclaw-model-selection-tiering-and-economics.md)
|
* [ADR 0002: OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics](docs/adr/0002-openclaw-model-selection-tiering-and-economics.md)
|
||||||
* [ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure](docs/adr/0003-everything-as-code-and-reproducible-infrastructure.md)
|
* [ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure](docs/adr/0003-everything-as-code-and-reproducible-infrastructure.md)
|
||||||
|
|
||||||
|
### 7. [Saschas Kronkorken Kreuzzug](./projects/sasch40/AGENTS.md) (`projects/sasch40/`)
|
||||||
|
* **Purpose**: Mobile-first 2.5D retro birthday adventure game for King Sascha's 40th jubilee (`https://sasch40.haumdaucher.de`).
|
||||||
|
* **Key Rules**: Prequel landing screen for QR codes, dynamic GameBoy arcade vs RPG layouts, real-photo character portraits, pure Web Audio synthesizer, competitive certificate scoring.
|
||||||
|
* **Read**: [projects/sasch40/AGENTS.md](./projects/sasch40/AGENTS.md)
|
||||||
|
|
||||||
|
### 8. [Moritz Graf Personal Website](./projects/moritzgrafde/AGENTS.md) (`projects/moritzgrafde/`)
|
||||||
|
* **Purpose**: Personal homepage and portfolio for Moritz Graf (`https://moritzgraf.de`).
|
||||||
|
* **Manifest**: `k8s/web/moritzgrafde.yaml` (Namespace: `web`).
|
||||||
|
* **Image**: `registry.haumdaucher.de/moritzgrafde:latest`.
|
||||||
|
* **Key Rules**: Executive portfolio built with Vue 3, Vite, TypeScript, and Tailwind CSS served via Nginx in Docker.
|
||||||
|
* **Read**: [projects/moritzgrafde/AGENTS.md](./projects/moritzgrafde/AGENTS.md)
|
||||||
|
|
||||||
|
### 9. [Haumdaucher Official Website](./projects/haumdaucherde/AGENTS.md) (`projects/haumdaucherde/`)
|
||||||
|
* **Purpose**: Official community platform and club portal for Haumdaucher Regensburg (`https://haumdaucher.de`).
|
||||||
|
* **Manifest**: `k8s/haumdaucher/haumdaucher.yaml` (Namespace: `haumdaucher`).
|
||||||
|
* **Image**: `registry.haumdaucher.de/haumdaucher-website:latest`.
|
||||||
|
* **Key Rules**: PWA built with Vue 3, Vite, TypeScript, and Firebase Auth; dynamic theming with NAT mode easter egg; strict compliance with German Vereinsrecht (§ 5 DDG Impressum for n.e.V.); user creation and mail forwarding Apps Script automations.
|
||||||
|
* **Read**: [projects/haumdaucherde/AGENTS.md](./projects/haumdaucherde/AGENTS.md)
|
||||||
|
|
||||||
|
### 10. [Grabengasse 7 Home Portal](./projects/grabengasse/AGENTS.md) (`projects/grabengasse/`)
|
||||||
|
* **Purpose**: Local climate, weather, and environmental dashboard for Grabengasse 7 (`https://grabengasse7.de`).
|
||||||
|
* **Manifests**: `k8s/grabengasse/` (`frontend.yaml`, `backend.yaml`, `ingress.yaml` in namespace `grabengasse`).
|
||||||
|
* **Images**: `registry.haumdaucher.de/website/grabengasse_frontend:latest`, `registry.haumdaucher.de/website/grabengasse_backend:latest`.
|
||||||
|
* **Key Rules**: Vue 3 retro pixel art frontend + FastAPI backend querying cluster Prometheus for Shelly WS90 weather telemetry.
|
||||||
|
* **Read**: [projects/grabengasse/AGENTS.md](./projects/grabengasse/AGENTS.md)
|
||||||
|
|
||||||
|
### 11. [Kitchen e-Paper Display](./projects/esphome_display_kitchen/AGENTS.md) (`projects/esphome_display_kitchen/`)
|
||||||
|
* **Purpose**: Dedicated ESP32 microcontroller driving the Waveshare e-Paper status display in the kitchen.
|
||||||
|
* **Integrations**: Home Assistant Native API (`input_text.epaper_message`), Prometheus client, SNTP time sync.
|
||||||
|
* **Key Tools**: Interactive `uv run flash.py` wizard and `./send_message.sh` CLI push messaging.
|
||||||
|
* **Read**: [projects/esphome_display_kitchen/AGENTS.md](./projects/esphome_display_kitchen/AGENTS.md)
|
||||||
|
|
||||||
|
### 12. [DuckDB Backend](./projects/duckdb-backend/AGENTS.md) (`projects/duckdb-backend/`)
|
||||||
|
* **Purpose**: High-performance HTTP analytics and event tracking data service executing in-memory DuckDB queries against Knowledge Wiki JSONL datasets.
|
||||||
|
* **Manifests**: `k8s/family/` (`duckdb-backend.yaml`, `pvc.yaml`, `ingress.yaml` in namespace `family`).
|
||||||
|
* **Endpoints**: Curated dashboards (`/duckdb-api/v1/mila/dashboard`, `/duckdb-api/v1/moritz/dashboard`), generic SQL queries (`/duckdb-api/v1/query`), and event tracking writes with Git sync.
|
||||||
|
* **Read**: [projects/duckdb-backend/AGENTS.md](./projects/duckdb-backend/AGENTS.md)
|
||||||
|
|
||||||
|
### 13. [Mila Tracking Web App](./projects/mila/AGENTS.md) (`projects/mila/`)
|
||||||
|
* **Purpose**: Reactive mobile-first PWA for Mila's baby sleep, feeding, diaper tracking, and growth percentiles (`https://family.haumdaucher.de/tracking/mila`).
|
||||||
|
* **Manifest**: `k8s/family/mila.yaml` (Namespace: `family`).
|
||||||
|
* **Key Rules**: Vue 3, Vite, TypeScript, and Tailwind CSS; live wake timer; interactive 1-tap and custom-time tracking modal.
|
||||||
|
* **Read**: [projects/mila/AGENTS.md](./projects/mila/AGENTS.md)
|
||||||
|
|
||||||
|
### 14. [Moritz Health & Nutrition Web App](./projects/moritz/AGENTS.md) (`projects/moritz/`)
|
||||||
|
* **Purpose**: Apple Health-inspired nutrition and metabolic balance dashboard (`https://family.haumdaucher.de/tracking/moritz`).
|
||||||
|
* **Manifest**: `k8s/family/moritz.yaml` (Namespace: `family`).
|
||||||
|
* **Key Rules**: Vue 3, Vite, TypeScript, and Tailwind CSS; net calorie balance hero, Garmin health breakdown, and quick meal logger.
|
||||||
|
* **Read**: [projects/moritz/AGENTS.md](./projects/moritz/AGENTS.md)
|
||||||
|
|
||||||
|
### 15. [Shelly Taupunktlüfter](./projects/shelly_taupunktluefter/AGENTS.md) (`projects/shelly_taupunktluefter/`)
|
||||||
|
* **Purpose**: Automated cellar ventilation controller ("Taupi") on Shelly Plug S Gen 3 to prevent mold growth using psychrometric calculations and BTHome BLE sensors.
|
||||||
|
* **Usage**: `cd projects/shelly_taupunktluefter && terraform apply`
|
||||||
|
* **Key Rules**: Sedlbauer LIM I decoupled mold danger evaluation, summer overheating and winter cold guards, optimal night drying window (03:00–07:00), and Prometheus metrics export.
|
||||||
|
* **Read**: [projects/shelly_taupunktluefter/AGENTS.md](./projects/shelly_taupunktluefter/AGENTS.md)
|
||||||
|
|
||||||
|
### 16. Utility Scripts (`scripts/`)
|
||||||
|
* **Purpose**: Cross-cutting operational and administration CLI scripts that do not belong inside Kubernetes namespace directories.
|
||||||
|
* **Key Tools**:
|
||||||
|
* `scripts/htpasswd.py`: Generates bcrypt-hashed htpasswd credentials for ingress basic auth secrets.
|
||||||
|
|
||||||
|
### 17. Network & Router Integration (`fritzbox/`)
|
||||||
|
* **Purpose**: Site-to-Site WireGuard LAN coupling and DynDNS OVH synchronization for the home FritzBox router.
|
||||||
|
* **Cluster Endpoint**: `k8s/wireguard/` (`vpn.haumdaucher.de`).
|
||||||
|
* **Read**: [fritzbox/README.md](./fritzbox/README.md)
|
||||||
|
|
||||||
## Global Constraints
|
## Global Constraints
|
||||||
* **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files.
|
* **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files.
|
||||||
* **Identity**:
|
* **Identity**:
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
# Infrapuzzle
|
# Infrapuzzle 🧩
|
||||||
|
|
||||||
Infrapuzzle is the newly restructured way of implementing my private infrastructure. Currently the following things are in project scope:
|
Infrapuzzle is the monorepo for personal infrastructure, Kubernetes cluster operations, DNS, IoT monitoring, and hosted applications.
|
||||||
|
|
||||||
* single node k8s "cluster" implemented with kubespray
|
## Repository Layout
|
||||||
* development with Vagrant
|
|
||||||
* host directory storage
|
|
||||||
* Ingress via host network
|
|
||||||
|
|
||||||
## terraform
|
- **[`bootstrap/`](bootstrap/)**: Provisioning the single-node Kubernetes cluster (`haumdaucher`) using Kubespray.
|
||||||
|
- **[`k8s/`](k8s/)**: Kubernetes workloads. **Every subdirectory represents a Kubernetes namespace.**
|
||||||
|
- **[`terraform/`](terraform/)**: Declarative Infrastructure as Code for core cloud infrastructure (OVH DNS zones, Authentik SSO, Google Workspace, and GCP APIs).
|
||||||
|
- **[`projects/`](projects/)**: Self-contained hosted applications and projects:
|
||||||
|
- `projects/shelly_taupunktluefter/`: Smart home Shelly Plug S ventilation controller ("Taupi") for cellar mold prevention.
|
||||||
|
- `projects/sasch40/`: Retro birthday adventure game (`sasch40.haumdaucher.de`).
|
||||||
|
- `projects/moritzgrafde/`: Personal homepage and executive portfolio (`moritzgraf.de`).
|
||||||
|
- `projects/haumdaucherde/`: Official community website & portal (`haumdaucher.de`).
|
||||||
|
- `projects/grabengasse/`: Climate & weather dashboard (`grabengasse7.de`).
|
||||||
|
- `projects/esphome_display_kitchen/`: ESP32 e-Paper kitchen status display & flashing wizard.
|
||||||
|
- `projects/duckdb-backend/`: In-memory analytics and tracking query API.
|
||||||
|
- `projects/mila/`: Baby tracking PWA (`family.haumdaucher.de/tracking/mila`).
|
||||||
|
- `projects/moritz/`: Health and nutrition dashboard (`family.haumdaucher.de/tracking/moritz`).
|
||||||
|
- **[`monitoring/`](monitoring/)**: ESPHome fleet for BLE ambient room temperature & humidity sensors.
|
||||||
|
- **[`fritzbox/`](fritzbox/)**: Home router Site-to-Site WireGuard VPN coupling and DynDNS synchronization.
|
||||||
|
- **[`scripts/`](scripts/)**: Operational utility scripts (e.g. `htpasswd.py`).
|
||||||
|
- **[`knowledge-wiki/`](knowledge-wiki/)**: Shared personal knowledge base and OpenClaw task queue submodule.
|
||||||
|
- **[`docs/`](docs/)**: Architecture Decision Records (ADRs) following the MADR standard.
|
||||||
|
|
||||||
[Documentation in subfolder](./terraform/README.md). It takes care of setting DNS records on OVH nameservers.
|
For architectural standards and operational guidelines, refer to [AGENTS.md](AGENTS.md).
|
||||||
|
|
||||||
## bootstrap
|
|
||||||
|
|
||||||
[Documentation in subfolder](./bootstrap/README.md). It takes care of setting up the k8s cluster itself, including a vagrant dev environment.
|
|
||||||
|
|
||||||
## k8s
|
|
||||||
|
|
||||||
[Documentation in subfolder](./k8s/README.md). The services themselfes.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Links used
|
|
||||||
|
|
||||||
* [ingress via host network](https://kubernetes.github.io/ingress-nginx/deploy/baremetal/#via-the-host-network)
|
|
||||||
* [Install K8s with Kubespray](https://kubernetes.io/docs/setup/production-environment/tools/kubespray/)
|
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
kubespray
|
kubespray
|
||||||
venv
|
venv
|
||||||
|
credentials/
|
||||||
|
inventory/**/credentials/
|
||||||
|
|||||||
+71
-37
@@ -1,49 +1,83 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This directory handles the **bootstrapping and provisioning** of the Haumdaucher Kubernetes cluster using **Kubespray**.
|
> This directory handles the **bootstrapping, provisioning, and in-place upgrades** of the Haumdaucher Kubernetes cluster using **Kubespray**.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
* **Tool**: [Kubespray](https://github.com/kubernetes-sigs/kubespray) (Ansible-based).
|
* **Tool**: [Kubespray](https://github.com/kubernetes-sigs/kubespray) (Ansible-based).
|
||||||
* **Method**: The local `inventory/` is the source of truth, which is synced into a checked-out Kubespray repository.
|
* **Topology**: **Single-node cluster** (`haumdaucher`, `136.243.23.215`). The single node acts simultaneously as control plane, etcd member, and worker.
|
||||||
* **Idempotency**: The process is designed to be repeatable. The `kubespray` folder is treated as ephemeral and is re-created by `init.sh`.
|
* **Host OS**: Ubuntu 20.04.6 LTS.
|
||||||
|
* **Method**: The local `inventory/` is the source of truth, synced into the checked-out Kubespray repository.
|
||||||
|
* **Idempotency & Patching**: The `kubespray/` directory is ephemeral and managed by `init.sh`. Any essential Kubespray fixes are persisted in `patches/` and applied automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Invariants for Single-Node Upgrades
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The single-node topology imposes strict operational constraints that must be observed by all agents and operators:
|
||||||
|
|
||||||
|
1. **NO DRAIN / NO CORDON**:
|
||||||
|
- Draining or cordoning a single-node cluster evicts all workloads with nowhere to schedule them, causing cascading failures and pending pods.
|
||||||
|
- `drain_nodes: false`, `upgrade_node_always_cordon: false`, and `upgrade_node_fail_if_drain_fails: false` MUST remain configured in [k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml).
|
||||||
|
- Use the in-place upgrade mechanism: `ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml`. This invokes `kubeadm-upgrade.yml` without cordon/drain overhead.
|
||||||
|
2. **VERSION STRING FORMAT**:
|
||||||
|
- In `inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, `kube_version` must be specified **without** the leading `v` (e.g., `1.32.13`, NOT `v1.32.13`), as Kubespray 2.28 interpolates `v{{ kube_version }}` in download URLs and package names.
|
||||||
|
3. **UBUNTU 20.04 OS CONSTRAINT**:
|
||||||
|
- Kubespray `release-2.28` is the **final release line** supporting Ubuntu 20.04 LTS. Upstream `release-2.29`+ drops Ubuntu 20.04 support. Do NOT bump `VERSION` in `init.sh` beyond `release-2.28` until the host operating system has been upgraded to Ubuntu 22.04 or 24.04.
|
||||||
|
4. **ETCD BACKUPS**:
|
||||||
|
- Always take a manual etcd snapshot before initiating any cluster upgrade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Workflow & Scripts
|
## Workflow & Scripts
|
||||||
The core workflow is encapsulated in `init.sh`.
|
|
||||||
|
|
||||||
### `init.sh`
|
### 1. Source `init.sh`
|
||||||
**Purpose**: Prepares the environment and Kubespray for deployment.
|
```bash
|
||||||
**Actions**:
|
source init.sh
|
||||||
1. **Clean Slate**: Deletes existing `kubespray/` directory.
|
```
|
||||||
2. **Clone**: Clones Kubespray (version defined in variable `VERSION`, e.g., `release-2.27`).
|
**Actions performed**:
|
||||||
3. **Environment**: Sets up Python virtualenv via `pyenv` and installs `requirements.txt`.
|
- Clones / checks out the Kubespray branch defined by `VERSION` (`release-2.28`).
|
||||||
4. **Sync**: Copies local `./inventory/` configurations into `./kubespray/inventory/`.
|
- Applies monorepo patches in `patches/*.patch` (e.g., propagating `--ignore-preflight-errors` for `v1beta4` kubeadm configs).
|
||||||
|
- Activates the `infrapuzzle-bootstrap` pyenv virtualenv and verifies Python requirements.
|
||||||
|
- Syncs `./inventory/` to `./kubespray/inventory/`.
|
||||||
|
- Exports the `sync-inventory` alias.
|
||||||
|
- Changes working directory to `./kubespray`.
|
||||||
|
|
||||||
### Usage
|
### 2. Upgrading the Single-Node Cluster (Two-Phase Decoupled Workflow)
|
||||||
1. **Source the script**:
|
|
||||||
```bash
|
#### Phase 1: Reconcile Kubespray Tooling Baseline (Keep existing k8s version)
|
||||||
source init.sh
|
```bash
|
||||||
```
|
cd kubespray
|
||||||
2. **Deploy / Upgrade**:
|
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||||
After sourcing, go to the `kubespray` directory and run the Ansible playbooks as instructed by the script output.
|
```
|
||||||
* **Standard Run**:
|
*Verification*: Check node readiness and that no application pods crashed.
|
||||||
```bash
|
|
||||||
cd kubespray
|
#### Phase 2: Upgrade Kubernetes Version (e.g. to 1.32.13)
|
||||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
1. Edit `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`:
|
||||||
```
|
```yaml
|
||||||
* **Forced Upgrade**:
|
kube_version: 1.32.13
|
||||||
```bash
|
```
|
||||||
cd kubespray
|
2. Re-sync inventory from `bootstrap/` or `bootstrap/kubespray/`:
|
||||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
```bash
|
||||||
```
|
sync-inventory
|
||||||
|
```
|
||||||
|
3. Run the in-place upgrade:
|
||||||
|
```bash
|
||||||
|
cd kubespray
|
||||||
|
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
||||||
|
```
|
||||||
|
4. Verify the cluster:
|
||||||
|
```bash
|
||||||
|
kubectl version
|
||||||
|
kubectl get nodes -o wide
|
||||||
|
kubectl get pods -A | grep -v -E "Running|Completed"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
* `init.sh`: The entry point script. **Source of truth for Kubespray version.**
|
* `init.sh`: The entrypoint script (sources environment, applies patches, sets up inventory).
|
||||||
* `inventory/`: Contains cluster inventory configurations (hosts, variables). **Edit this, not the one in `kubespray/`**.
|
* `patches/`: Monorepo patches applied automatically to ephemeral `kubespray/`.
|
||||||
* `kubespray/`: (Ignored/Ephemeral) The checked-out Kubespray repository. **Do not edit files here directly**; they will be overwritten.
|
* `inventory/`: Source of truth for cluster inventory and variables.
|
||||||
|
* `kubespray/`: (Ephemeral / Gitignored) Cloned Kubespray repository.
|
||||||
## Configuration Updates
|
|
||||||
To upgrade Kubespray or change cluster config:
|
|
||||||
1. **Version Upgrade**: Update `VERSION` in `init.sh` (e.g., to `release-2.28`).
|
|
||||||
2. **Config Changes**: Modify files in `./inventory/`.
|
|
||||||
3. **Apply**: Run `source init.sh` then execute the Ansible playbook.
|
|
||||||
+132
-81
@@ -1,114 +1,165 @@
|
|||||||
# Bootstrap
|
# Bootstrap
|
||||||
|
|
||||||
The following lines document how to initalize a fresh cluster. On a real cluster, or using Vagrant. It therefore assumes to clone kubespray to this folder. It will be excluded in *.gitignore* and all files are in this folder.
|
This directory contains the automation, configurations, and playbooks used to provision and upgrade the single-node Kubernetes cluster **`haumdaucher`** (`136.243.23.215`) using **[Kubespray](https://github.com/kubernetes-sigs/kubespray)**.
|
||||||
|
|
||||||
The version to be initialized in configured in the `init.sh` script.
|
---
|
||||||
|
|
||||||
|
## Architecture & Single-Node Cluster Constraints
|
||||||
|
|
||||||
|
`haumdaucher` is a **single-node cluster** (control plane, etcd, and worker all run on the same machine). This topology requires specific configuration choices during cluster maintenance:
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> ### Crucial Single-Node Invariants
|
||||||
|
> 1. **Never Cordon or Drain the Node**:
|
||||||
|
> Draining a single-node cluster evicts all workloads with nowhere to schedule them, causing pods to stay in `Pending` state and breaking critical cluster services (such as DNS, CNI, ingress, and local storage).
|
||||||
|
> In [inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml), the following settings are enforced:
|
||||||
|
> ```yaml
|
||||||
|
> drain_nodes: false
|
||||||
|
> upgrade_node_always_cordon: false
|
||||||
|
> upgrade_node_fail_if_drain_fails: false
|
||||||
|
> ```
|
||||||
|
> 2. **In-Place Upgrades via `cluster.yml`**:
|
||||||
|
> Instead of `upgrade-cluster.yml` (which defaults to draining nodes), single-node upgrades should use the in-place upgrade mechanism:
|
||||||
|
> ```sh
|
||||||
|
> ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
||||||
|
> ```
|
||||||
|
> 3. **Version Number Formatting**:
|
||||||
|
> Kubespray 2.28 interpolates `v{{ kube_version }}` across many internal tasks. Therefore, `kube_version` in `k8s-cluster.yml` MUST NOT include a leading `v`:
|
||||||
|
> ```yaml
|
||||||
|
> kube_version: 1.32.13 # Correct
|
||||||
|
> # kube_version: v1.32.13 # INCORRECT (leads to v1.32.13 -> vv1.32.13 errors)
|
||||||
|
> ```
|
||||||
|
> 4. **Host OS Lifecycle Constraint (Ubuntu 20.04 LTS)**:
|
||||||
|
> Kubespray `release-2.28` is the **final release line supporting Ubuntu 20.04**. Kubespray `release-2.29`+ drops Ubuntu 20.04 support and requires Ubuntu 22.04 or 24.04. Do not bump `VERSION` in `init.sh` beyond `release-2.28` until the host OS is upgraded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
### 1. Environment Initialization
|
||||||
|
Source `init.sh` from this directory. It clones Kubespray, applies patches from `patches/`, configures the Python virtualenv, and syncs `./inventory/` into `./kubespray/inventory/`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
. ./init.sh
|
source ./init.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
See [kubespray.io](https://kubespray.io/) on detailed information about kubespray. Though it seems to be a littlebit outdated.
|
*(Note: `init.sh` also exports the `sync-inventory` alias to quickly sync local `./inventory/` modifications into `./kubespray/inventory/` without re-cloning).*
|
||||||
|
|
||||||
## Vagrant
|
---
|
||||||
|
|
||||||
```sh
|
## Step-by-Step Upgrade Runbook
|
||||||
cd kubespray
|
|
||||||
vagrant up
|
|
||||||
# up and abkle to ssh
|
|
||||||
vagrant ssh k8s-1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prod
|
Upgrades are conducted in two decoupled phases:
|
||||||
|
1. **Phase 1**: Upgrade the Kubespray tooling baseline while keeping the Kubernetes version unchanged $\rightarrow$ Apply $\rightarrow$ Verify.
|
||||||
|
2. **Phase 2**: Upgrade Kubernetes components (API server, controller manager, scheduler, kubelet, kube-proxy) $\rightarrow$ Apply $\rightarrow$ Verify.
|
||||||
|
|
||||||
Prepare server:
|
### Step 0: Pre-Flight Checks & Backups
|
||||||
* deactivate swap!
|
|
||||||
* `moritz username ALL=(ALL) NOPASSWD:ALL`
|
|
||||||
* `
|
|
||||||
|
|
||||||
```sh
|
1. **Verify SSH and sudo access**:
|
||||||
ssh centos@<ip>
|
```sh
|
||||||
# auth via pw
|
ssh moritz@136.243.23.215 "sudo kubectl get nodes -o wide"
|
||||||
sudo su - root
|
```
|
||||||
adduser moritz
|
|
||||||
visudo # add as sudo user
|
|
||||||
su - moritz
|
|
||||||
sudo yum -y install vim python3
|
|
||||||
ssh-keygen
|
|
||||||
vim .ssh/authorized_users # paste key
|
|
||||||
chmod 644 .ssh/authorized_keys
|
|
||||||
# check whether login works with ssh key
|
|
||||||
sudo vim /etc/ssh/sshd_config # remove pw auth & root login
|
|
||||||
sudo yum upgrade -y && sudo reboot
|
|
||||||
```
|
|
||||||
|
|
||||||
Install Kubernetes:
|
2. **Check Pod Health**:
|
||||||
|
```sh
|
||||||
|
ssh moritz@136.243.23.215 "sudo kubectl get pods -A | grep -v -E 'Running|Completed'"
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
3. **Take a Manual etcd Snapshot**:
|
||||||
. ./init.sh
|
```sh
|
||||||
# follow instructions from output, sth like:
|
ssh moritz@136.243.23.215 "sudo etcdctl \
|
||||||
cd kubespray
|
--cacert=/etc/ssl/etcd/ssl/ca.pem \
|
||||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
--cert=/etc/ssl/etcd/ssl/node-haumdaucher.pem \
|
||||||
```
|
--key=/etc/ssl/etcd/ssl/node-haumdaucher-key.pem \
|
||||||
|
snapshot save /var/backups/etcd/etcd-pre-upgrade-\$(date +%Y%m%d%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
And get credentials:
|
---
|
||||||
|
|
||||||
```sh
|
### Step 1: Reconcile Kubespray Baseline (Phase 1)
|
||||||
ssh <ip>
|
|
||||||
sudo su - root
|
|
||||||
cd
|
|
||||||
cp -r .kube /home/moritz/
|
|
||||||
chown -R moritz. /home/moritz/.kube
|
|
||||||
#ctrl + d
|
|
||||||
kubectl get ns # test connection
|
|
||||||
#ctrl + d
|
|
||||||
scp haumdaucher.de:/home/moritz/.kube/config .kube/config
|
|
||||||
```
|
|
||||||
|
|
||||||
Foreward in k8s-directory.
|
1. Verify `VERSION` in `init.sh` matches the target Kubespray branch (e.g. `release-2.28`).
|
||||||
|
2. Run `source ./init.sh` to initialize the environment.
|
||||||
|
3. Apply the baseline configuration:
|
||||||
|
```sh
|
||||||
|
cd kubespray
|
||||||
|
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||||
|
```
|
||||||
|
4. Verify cluster health:
|
||||||
|
```sh
|
||||||
|
kubectl get nodes -o wide
|
||||||
|
kubectl get pods -n kube-system
|
||||||
|
```
|
||||||
|
|
||||||
## Upgrade cluster
|
---
|
||||||
|
|
||||||
Check the current default value of `kube_version` in cloned repository.
|
### Step 2: Upgrade Kubernetes Version (Phase 2)
|
||||||
|
|
||||||
**FOR K8S VERSION - Edit /Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml**
|
1. In `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, update `kube_version`:
|
||||||
|
```yaml
|
||||||
|
kube_version: 1.32.13
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
2. Sync the configuration into the `kubespray/` workspace:
|
||||||
cd kubespray
|
```sh
|
||||||
ansible-playbook -i inventory/prod/inventory.ini -e kube_version=v1.22.10 -e upgrade_cluster_setup=true cluster.yml
|
sync-inventory
|
||||||
# or just the newest version
|
```
|
||||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
|
||||||
# upgrade to specific calico version (did not trigger/ failed)
|
|
||||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e calico_version=v3.15.2 cluster.yml --tags=network
|
|
||||||
```
|
|
||||||
|
|
||||||
History:
|
3. Run the in-place upgrade playbook:
|
||||||
|
```sh
|
||||||
|
cd kubespray
|
||||||
|
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
||||||
|
```
|
||||||
|
|
||||||
* 2020-04-18 kube_version=v1.16.8 kubespray_branch=release-2.12
|
---
|
||||||
|
|
||||||
## Add node
|
### Step 3: Post-Upgrade Verification
|
||||||
|
|
||||||
See [documentation](https://github.com/kubernetes-sigs/kubespray/blob/master/docs/nodes.md).
|
1. **Check component versions**:
|
||||||
|
```sh
|
||||||
|
kubectl version
|
||||||
|
```
|
||||||
|
Ensure both `Client Version` and `Server Version` report `v1.32.13`.
|
||||||
|
|
||||||
Note: This was more or less a trial and error approach. Running different playbooks over and over again got it right at some point.
|
2. **Verify Node status**:
|
||||||
|
```sh
|
||||||
|
kubectl get nodes -o wide
|
||||||
|
```
|
||||||
|
Ensure status is `Ready` and version matches `v1.32.13`.
|
||||||
|
|
||||||
```sh
|
3. **Verify System Pods**:
|
||||||
ansible-playbook -i inventory/prod/inventory.ini --limit=ns3088070.ip-37-59-40.eu,ns3100058.ip-37-59-61.eu scale.yml
|
```sh
|
||||||
ansible-playbook -i inventory/prod/inventory.ini --limit=etcd,kube-master -e ignore_assert_errors=yes cluster.yml
|
kubectl get pods -n kube-system
|
||||||
```
|
```
|
||||||
|
All control plane components (`kube-apiserver`, `kube-controller-manager`, `kube-scheduler`, `coredns`, `kube-flannel`, `kube-proxy`, `traefik`) should be `1/1 Running`.
|
||||||
|
|
||||||
This runs everything and is kind of idempotent:
|
4. **Verify Workloads**:
|
||||||
|
```sh
|
||||||
|
kubectl get pods -A | grep -v -E "Running|Completed"
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
---
|
||||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Upgrade to 2.31.3
|
## Troubleshooting & Edge Cases
|
||||||
|
|
||||||
Required to execute:
|
### Kubeadm Preflight `CreateJob` Failure
|
||||||
|
Kubeadm 1.31+ preflight executes an ephemeral `upgrade-health-check-...` batch job with a 15-second timeout. On single-node clusters or under load, this may exceed 15s. To prevent the upgrade from halting:
|
||||||
|
- `CreateJob` is included in `kubeadm_ignore_preflight_errors` in [k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml).
|
||||||
|
- The patch `patches/kubeadm-upgrade-ignore-preflight.patch` ensures Kubespray's `v1beta4` kubeadm configuration propagates this flag during `upgrade apply`.
|
||||||
|
|
||||||
```
|
### Kubelet Housekeeping Stalls / Static Pod Timeout
|
||||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e drain_nodes=false upgrade-cluster.yml
|
If `kubeadm upgrade apply` times out waiting for static pod hash changes:
|
||||||
```
|
- Check `journalctl -u kubelet -n 100` for `Housekeeping took longer than expected` or `orphaned pod ... failed to rmdir()`.
|
||||||
|
- If an old container unmount left a stale bind-mount busy, lazily detach it:
|
||||||
|
```sh
|
||||||
|
sudo umount -l /var/lib/kubelet/pods/<orphaned-pod-uid>/volumes/...
|
||||||
|
sudo systemctl restart kubelet
|
||||||
|
```
|
||||||
|
|
||||||
+ set a feature flag: https://github.com/kubernetes-sigs/kubespray/issues/11887
|
---
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
* `init.sh`: Environment bootstrapping script and upgrade instructions.
|
||||||
|
* `inventory/`: Source-of-truth inventory and variable files.
|
||||||
|
* `patches/`: Git patches applied to upstream Kubespray on checkout.
|
||||||
|
* `kubespray/`: (Ephemeral) Cloned Kubespray repository.
|
||||||
|
|||||||
+67
-49
@@ -1,60 +1,78 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# check whether script is sourced
|
# Ensure script is sourced, not executed directly
|
||||||
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "script is executed, but needs to be sourced" && exit 1
|
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "Error: init.sh must be sourced: source ./init.sh (or . ./init.sh)" && exit 1
|
||||||
|
|
||||||
echo "######################################################################################"
|
BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
echo "## Reinit repository"
|
VERSION="release-2.28" # Note: release-2.28 is the final release line supporting Ubuntu 20.04 LTS
|
||||||
rm -rf kubespray
|
|
||||||
VERSION="release-2.27"
|
|
||||||
git clone --branch $VERSION https://github.com/kubernetes-sigs/kubespray.git
|
|
||||||
|
|
||||||
echo "######################################################################################"
|
echo "======================================================================================"
|
||||||
echo "## Activating pyenv venv"
|
echo "## [1/4] Checking / Cloning Kubespray ($VERSION)"
|
||||||
|
echo "======================================================================================"
|
||||||
|
if [ ! -d "$BOOTSTRAP_DIR/kubespray/.git" ]; then
|
||||||
|
rm -rf "$BOOTSTRAP_DIR/kubespray"
|
||||||
|
git clone --branch "$VERSION" --depth 1 https://github.com/kubernetes-sigs/kubespray.git "$BOOTSTRAP_DIR/kubespray"
|
||||||
|
else
|
||||||
|
echo "Existing kubespray checkout detected. Ensuring correct branch..."
|
||||||
|
git -C "$BOOTSTRAP_DIR/kubespray" fetch origin "$VERSION" --depth 1
|
||||||
|
git -C "$BOOTSTRAP_DIR/kubespray" checkout "$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$BOOTSTRAP_DIR/patches" ]; then
|
||||||
|
for p in "$BOOTSTRAP_DIR"/patches/*.patch; do
|
||||||
|
if [ -f "$p" ]; then
|
||||||
|
echo "## Applying patch $(basename "$p")..."
|
||||||
|
git -C "$BOOTSTRAP_DIR/kubespray" apply --check "$p" 2>/dev/null && git -C "$BOOTSTRAP_DIR/kubespray" apply "$p" || echo "Patch $(basename "$p") already applied or skipped."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "======================================================================================"
|
||||||
|
echo "## [2/4] Activating Python Virtualenv"
|
||||||
|
echo "======================================================================================"
|
||||||
eval "$(pyenv init -)"
|
eval "$(pyenv init -)"
|
||||||
eval "$(pyenv virtualenv-init -)"
|
eval "$(pyenv virtualenv-init -)"
|
||||||
pyenv virtualenv 3.10.8 infrapuzzle-bootstrap
|
pyenv virtualenvs --bare | grep -q "^infrapuzzle-bootstrap$" || pyenv virtualenv 3.10.8 infrapuzzle-bootstrap
|
||||||
pyenv activate infrapuzzle-bootstrap
|
pyenv activate infrapuzzle-bootstrap
|
||||||
python -m pip install -r kubespray/requirements.txt
|
python -m pip install -q -r "$BOOTSTRAP_DIR/kubespray/requirements.txt"
|
||||||
|
|
||||||
|
echo "======================================================================================"
|
||||||
|
echo "## [3/4] Syncing Inventory Configuration"
|
||||||
|
echo "======================================================================================"
|
||||||
|
rsync -a "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
|
||||||
|
|
||||||
echo "######################################################################################"
|
# Helper function for re-syncing changes made in ./inventory to ephemeral ./kubespray/inventory
|
||||||
echo "## Customizing vagrant dev env"
|
sync-inventory() {
|
||||||
mkdir -p kubespray/vagrant
|
rsync -av "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
|
||||||
cat << EOF > kubespray/vagrant/config.rb
|
}
|
||||||
\$instance_name_prefix = "k8s"
|
export -f sync-inventory 2>/dev/null || true
|
||||||
\$vm_cpus = 4
|
alias sync-inventory="rsync -av '$BOOTSTRAP_DIR/inventory/' '$BOOTSTRAP_DIR/kubespray/inventory/'"
|
||||||
\$num_instances = 1
|
|
||||||
\$os = "centos"
|
|
||||||
\$subnet = "10.0.20"
|
|
||||||
\$network_plugin = "calico"
|
|
||||||
\$shared_folders = { 'temp/docker_rpms' => "/var/cache/yum/x86_64/7/docker-ce/packages" }
|
|
||||||
\$kube_node_instances_with_disks_number = 0
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# make the rpm cache
|
echo "======================================================================================"
|
||||||
mkdir -p kubespray/temp/docker_rpms
|
echo "## [4/4] Environment Ready! Single-Node Cluster Upgrade Workflow"
|
||||||
|
echo "======================================================================================"
|
||||||
echo "###############"
|
|
||||||
echo "Now cd to kubespray and 'vagrant up'"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "export KUBECONFIG=\"$( pwd )/kubespray/inventory/sample/artifacts/admin.conf\""
|
echo "Current directory changed to: $BOOTSTRAP_DIR/kubespray"
|
||||||
|
cd "$BOOTSTRAP_DIR/kubespray"
|
||||||
|
echo ""
|
||||||
echo "######################################################################################"
|
echo "=== PRE-UPGRADE CHECKLIST ==="
|
||||||
echo "## * syncing config in kubespray dir"
|
echo "1. Verify SSH connection and sudo access:"
|
||||||
rsync -a ./inventory/ ./kubespray/inventory/
|
echo " ssh moritz@136.243.23.215 'sudo kubectl get nodes -o wide'"
|
||||||
echo "## * changing into kubespray dir"
|
echo "2. Take manual etcd snapshot before upgrading:"
|
||||||
cd kubespray
|
echo " ssh moritz@136.243.23.215 'sudo etcdctl --cacert=/etc/ssl/etcd/ssl/ca.pem --cert=/etc/ssl/etcd/ssl/node-haumdaucher.pem --key=/etc/ssl/etcd/ssl/node-haumdaucher-key.pem snapshot save /var/backups/etcd/etcd-pre-upgrade-\$(date +%Y%m%d%H%M%S).db'"
|
||||||
echo "## * execute the following command to force new settings"
|
echo ""
|
||||||
echo "ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
|
echo "=== TWO-PHASE UPGRADE WORKFLOW ==="
|
||||||
echo "## * execute the following command to force new settings with forced cluster upgrade"
|
echo "Phase 1: Reconcile Kubespray baseline (keeps current k8s version):"
|
||||||
echo "ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
|
echo " ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
|
||||||
|
echo ""
|
||||||
# cp -r kubespray/inventory/sample kubespray/inventory/prod
|
echo "Phase 2: Upgrade Kubernetes version (e.g. to 1.32.13):"
|
||||||
# rm kubespray/inventory/prod/inventory.ini
|
echo " a) Update 'kube_version: <target>' in ../inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml"
|
||||||
# cp ./prod.ini kubespray/inventory/prod/inventory.ini
|
echo " b) Sync inventory: sync-inventory"
|
||||||
#gsed -i "s/kube_network_plugin: .*/kube_network_plugin: flannel/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml
|
echo " c) Run in-place single-node upgrade:"
|
||||||
# gsed -i "s/# calico_iptables_backend: \"Legacy\"/calico_iptables_backend: \"NFT\"/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-calico.yml
|
echo " ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
|
||||||
#echo 'calico_iptables_backend: "NFT"' >> ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-cluster.yml
|
echo ""
|
||||||
#gsed -i "s/metrics_server_enabled: .*/metrics_server_enabled: true/" ./kubespray/inventory/prod/group_vars/k8s-cluster/addons.yml
|
echo "=== POST-UPGRADE VERIFICATION ==="
|
||||||
|
echo " kubectl version"
|
||||||
|
echo " kubectl get nodes -o wide"
|
||||||
|
echo " kubectl get pods -A | grep -v -E 'Running|Completed'"
|
||||||
|
echo "======================================================================================"
|
||||||
|
|||||||
@@ -27,7 +27,17 @@ kube_kubeadm_apiserver_extra_args:
|
|||||||
kube_api_anonymous_auth: true
|
kube_api_anonymous_auth: true
|
||||||
|
|
||||||
## Change this to use another Kubernetes version, e.g. a current beta release
|
## Change this to use another Kubernetes version, e.g. a current beta release
|
||||||
kube_version: v1.31.9
|
kube_version: 1.32.13
|
||||||
|
|
||||||
|
# Single-node cluster settings: prevent draining or cordoning the only node
|
||||||
|
drain_nodes: false
|
||||||
|
upgrade_node_always_cordon: false
|
||||||
|
upgrade_node_fail_if_drain_fails: false
|
||||||
|
kubeadm_ignore_preflight_errors:
|
||||||
|
- ImagePull
|
||||||
|
- SystemVerification
|
||||||
|
- FileContent--proc-sys-net-bridge-bridge-nf-call-iptables
|
||||||
|
- CreateJob
|
||||||
|
|
||||||
# kubernetes image repo define
|
# kubernetes image repo define
|
||||||
#kube_image_repo: "k8s.gcr.io"
|
#kube_image_repo: "k8s.gcr.io"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
diff --git a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
|
||||||
|
index 523956d1d..75723a6e0 100644
|
||||||
|
--- a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
|
||||||
|
+++ b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
|
||||||
|
@@ -17,6 +17,7 @@
|
||||||
|
--force
|
||||||
|
{%- else %}
|
||||||
|
--config={{ kube_config_dir }}/kubeadm-config.yaml
|
||||||
|
+ --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if kube_version is version('1.32.0', '>=') %}
|
||||||
|
--skip-phases={{ kubeadm_init_phases_skip | join(',') }}
|
||||||
|
@@ -38,6 +39,7 @@
|
||||||
|
{% if kubeadm_patches | length > 0 %}--patches={{ kubeadm_patches_dir }}{% endif %}
|
||||||
|
{%- else %}
|
||||||
|
--config={{ kube_config_dir }}/kubeadm-config.yaml
|
||||||
|
+ --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
|
||||||
|
{%- endif %}
|
||||||
|
--skip-phases={{ kubeadm_upgrade_node_phases_skip | join(',') }}
|
||||||
|
register: kubeadm_upgrade
|
||||||
@@ -139,8 +139,7 @@ Subagent specifications, system prompts, tool assignments, and domain guidelines
|
|||||||
|
|
||||||
### 3. Multi-Turn Continuity: Domain Stickiness & Rich State Bridge
|
### 3. Multi-Turn Continuity: Domain Stickiness & Rich State Bridge
|
||||||
To prevent context loss during ongoing step-by-step dialogues (e.g. interactive cooking or workout design):
|
To prevent context loss during ongoing step-by-step dialogues (e.g. interactive cooking or workout design):
|
||||||
* **Domain Stickiness**: Follow-up questions on the active topic remain within the specialized subagent domain until an explicit topic shift occurs.
|
* **Persistent Context Continuity**: Follow-up turns are routed within the dedicated subagent topic session using native conversational memory, keeping user-facing messages clean without embedding raw JSON/HTML metadata tags.
|
||||||
* **Rich State Bridge (`<!-- DOMAIN_STATE -->`)**: Every subagent embeds a compact JSON state comment at the end of its response. The Conductor uses this state to answer trivial micro-checks in < 1s, or feeds it back into the subagent for complex modifications.
|
|
||||||
|
|
||||||
### 4. 3-Tier Session Lifecycle (Preventing Unbounded Session Growth)
|
### 4. 3-Tier Session Lifecycle (Preventing Unbounded Session Growth)
|
||||||
1. **Conversational Close**: Topic shifts or explicit user completion (*"Danke, das war's"*) reset the active domain pointer.
|
1. **Conversational Close**: Topic shifts or explicit user completion (*"Danke, das war's"*) reset the active domain pointer.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: "accepted (trial: GLM-5.3-Flash)"
|
status: "accepted (fleet-wide primary: DeepSeek-V4.1-Flash-1017; updated 2026-10-17)"
|
||||||
date: 2026-09-13
|
date: 2026-09-13
|
||||||
decision-makers: ["Moritz Graf", "Antigravity"]
|
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||||
consulted: []
|
consulted: []
|
||||||
@@ -28,7 +28,7 @@ How do we architect the full-spectrum model selection across **all modalities**
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. Gemini 3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
|
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
|
||||||
* **Conversational & Reasoning Quality**: High emotional intelligence, nuanced German fluency, personal context recall, and strict adherence to tool-calling protocols (ADR 0001).
|
* **Conversational & Reasoning Quality**: High emotional intelligence, nuanced German fluency, personal context recall, and strict adherence to tool-calling protocols (ADR 0001).
|
||||||
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
|
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
|
||||||
- Text & Agentic Reasoning
|
- Text & Agentic Reasoning
|
||||||
@@ -38,9 +38,9 @@ How do we architect the full-spectrum model selection across **all modalities**
|
|||||||
- Text-to-Speech / Audio Output (synthesizing briefings and podcasts)
|
- Text-to-Speech / Audio Output (synthesizing briefings and podcasts)
|
||||||
- Web Search & Retrieval (grounded search)
|
- Web Search & Retrieval (grounded search)
|
||||||
- Semantic Embeddings (vector memory)
|
- Semantic Embeddings (vector memory)
|
||||||
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), zero background bleed, and predictable billing.
|
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), high prompt-cache discount efficiency (`cacheRead`), zero background bleed, and predictable billing.
|
||||||
* **Latency & Turn Responsiveness**: Conversational turns on Telegram must return within **4–10 seconds**, avoiding provider queuing or massive prefill bottlenecks.
|
* **Latency & Turn Responsiveness**: Conversational turns on Telegram must return within **4–10 seconds**, avoiding provider queuing or massive prefill bottlenecks.
|
||||||
* **Resilience & Fault Tolerance**: Automated fallback chains across independent providers on OpenRouter.
|
* **Resilience & Fault Tolerance**: Automated fallback chains and strict quantization/provider pins (`fp8`/`bf16`, `tool_choice` support) across independent providers on OpenRouter.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,24 +60,25 @@ How do we architect the full-spectrum model selection across **all modalities**
|
|||||||
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
|
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
|
||||||
│ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │
|
│ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │
|
||||||
│ • Tool Routing │ │ • Autonomous │ │ • Zero Bleed │ │ • Dedicated │ │ • Gemini 3.1 │
|
│ • Tool Routing │ │ • Autonomous │ │ • Zero Bleed │ │ • Dedicated │ │ • Gemini 3.1 │
|
||||||
│ • Native Vision │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
|
│ • Decoupled OCR │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
|
||||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
│ Primary (Trial):│ │ Primary (Trial):│ │ Primary: │ │ Primary: │ │ STT: │
|
│ Primary: │ │ Primary: │ │ Primary: │ │ Primary: │ │ STT: │
|
||||||
│ z-ai/ │ │ z-ai/ │ │ z-ai/ │ │ google/ │ │ openai/ │
|
│ deepseek/ │ │ deepseek/ │ │ deepseek/ │ │ google/ │ │ openai/ │
|
||||||
│ glm-5.3-flash │ │ glm-5.3-flash │ │ glm-5.3-flash │ │ gemini-3.1- │ │ whisper-large- │
|
│ deepseek-v4.1- │ │ deepseek-v4.1- │ │ deepseek-v4.1- │ │ gemini-3.1- │ │ whisper-large- │
|
||||||
│ │ │ │ │ │ │ flash-image │ │ v3-turbo │
|
│ flash-1017 │ │ flash-1017 │ │ flash-1017 │ │ flash-image │ │ v3-turbo │
|
||||||
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ │ │
|
│ │ │ │ │ │ │ │ │
|
||||||
│ 1. deepseek- │ │ 1. deepseek- │ │ 1. deepseek- │ │ Fallbacks: │ │ TTS: │
|
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ TTS: │
|
||||||
│ v4.1-flash │ │ v4.1-flash │ │ v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
|
│ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
|
||||||
│ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ flash-preview│ │ gemini-3.1- │
|
│ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ flash-preview│ │ gemini-3.1- │
|
||||||
│ │ │ │ │ │ │ 2. gemini-3-pro-│ │ flash-tts │
|
│ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 2. gemini-3-pro-│ │ flash-tts │
|
||||||
│ Vision Fallback:│ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
|
│ │ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
|
||||||
│ google/ │ │ │ │ │ │ │ │ Speech HD) │
|
│ Vision (OCR): │ │ │ │ │ │ │ │ Speech HD) │
|
||||||
│ gemini-3.1- │ │ │ │ │ │ │ │ │
|
│ google/ │ │ │ │ │ │ │ │ │
|
||||||
│ flash-image │ │ │ │ │ │ │ │ │
|
│ gemini-3.8- │ │ │ │ │ │ │ │ │
|
||||||
|
│ flash │ │ │ │ │ │ │ │ │
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
|
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,29 +87,35 @@ How do we architect the full-spectrum model selection across **all modalities**
|
|||||||
## Modality Specifications & Cost-Benefit Analysis
|
## Modality Specifications & Cost-Benefit Analysis
|
||||||
|
|
||||||
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
|
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
|
||||||
* **Primary (Active Trial)**: `openrouter/z-ai/glm-5.3-flash`
|
* **Primary (Fleet-Wide Standard as of 2026-10-17)**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
|
||||||
- **Pricing**: \$0.075 / 1M prompt tokens; \$0.300 / 1M completion tokens. This is 50–75% more cost-effective than DeepSeek V4.1-Flash and offers a 1.3M token context window. Under daytime context headroom (~25k–60k tokens), daily cost is only **~\$0.01–\$0.02 / day**.
|
- **1017 Snapshot Upgrade (Pareto Jump in Quality & Price)**: Released on October 17, 2026, `deepseek/deepseek-v4.1-flash-1017` boosts the Artificial Analysis Intelligence Index from **24.7 to 39.5** (+60%) via Reinforcement Learning with Self-Feedback (RLSF) while cutting DeepInfra `fp8` pricing by an additional **30%** (`$0.098/M` prompt, `$0.294/M` completion, and **`$0.00294/M` `cacheRead`** vs. `$0.140/$0.420/$0.0042` on the 0910 baseline).
|
||||||
- **Performance & Reasoning**: SOTA Chinese & English reasoning, competitive coding benchmarks, and native vision perception.
|
- **Provider Pinning & Tool-Compatibility Verification**:
|
||||||
- **Forensic Diagnosis & Tool-Calling Protocol Fix**:
|
- Pinned `order: ["deepinfra", "cloudflare", "inference-net", "fireworks", "parasail"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]`.
|
||||||
- *Incident*: On September 14, 2026, GLM-5.3-Flash emitted raw `<tool_call>supermarket__supermarket_search...</tool_call>` XML into Telegram instead of executing tools.
|
- **Cloudflare (#2 Promotion)**: Promoted ahead of InferenceNet and Parasail because Cloudflare delivers a 97% cache discount (`$0.003/M` `cacheRead`), 3× lower completion costs (`$0.60/M` vs. `$1.20/M` on Parasail), and a massive **943,718** completion token limit.
|
||||||
- *Root Cause Identified*: In Zhipu AI's pre-training tokenizer, function calls are emitted as special XML tokens. When models are queried through OpenRouter's OpenAI-compatible API, OpenRouter only intercepts and translates `<tool_call>` tokens into JSON if the tool `name` exists in the request's registered `tools: [...]` array. OpenClaw uses dynamic Tool Search (`tools.toolSearch.mode: "directory"`) where only 3 control tools (`tool_search`, `tool_describe`, `tool_call`) are registered in `tools`. When GLM received deferred schemas (e.g., `supermarket__supermarket_search`), its pre-training prior prompted it to call the tool directly. Because `supermarket__supermarket_search` was not in OpenRouter's `tools` array, OpenRouter passed the raw XML through as message text. Once that text entered the session history (events SEQ 297 & 306), GLM was in-context primed to repeat the XML pattern.
|
- **Fireworks (#4 Addition)**: Onboarded as a high-reliability enterprise tier-1 provider (`$0.30/M` prompt, `$1.20/M` completion, `$0.006/M` `cacheRead`, 100% uptime, 943k max completion tokens, full tool choice and structured outputs support).
|
||||||
- *Remediation & Verification*: We introduced an explicit deferred tool execution protocol into `AGENTS.md` instructing the model that deferred tools must NEVER be called directly and MUST be routed via `tool_call({ id: 'tool_name', args: { ... } })`. Direct OpenRouter API probes confirmed that with this rule, GLM-5.3-Flash calls `tool_call` with 100% compliant JSON and zero XML leakage. Session events were sanitized.
|
- **Completion Token Headroom Guardrail (`maxTokens: 65536`)**: Because reasoning models share the completion token budget between internal chain-of-thought (`reasoningTokens`, typically 2k–4k tokens) and the actual tool-call payload, omitting `maxTokens` previously allowed OpenClaw/OpenRouter to default to an 8,192 token ceiling. During large multi-kilobyte code or website generation, hitting 8,192 tokens abruptly truncated tool-call JSON mid-payload (`Provider returned an incomplete or malformed tool call`). Explicitly configuring `maxTokens: 65536` and `params.max_tokens: 65536` provides ample headroom for complex reasoning and large outputs.
|
||||||
|
- Live OpenRouter routing forensics verified that `morph/fp8` is filtered out by OpenRouter's `Filter by Tool Compatibility` when `tools` are passed, `deepseek` (official) is filtered by account privacy guardrails (`Paid model training violation`), and `baseten` returns `HTTP 429`. Meanwhile, `deepinfra`, `cloudflare`, `inference-net`, `fireworks`, and `parasail` deliver 100% tool-calling reliability and sub-second to 3s latency.
|
||||||
|
- **Post-Trial Evaluation of `z-ai/glm-5.3-flash` (2026-09-13 to 2026-10-17)**:
|
||||||
|
- While `z-ai/glm-5.3-flash` was trialed as primary conductor, 17-day production telemetry (Oct 1–17, 2026) revealed **12 mid-stream tool-call aborts** (`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`) across 5 agents (`main`, `grabengasse_media`, `koch`, `hebamme`, `doktor`) when generating nested `tool_call({ id, args })` payloads after `tool_describe`, as well as occasional thinking-only whitespace stalls (Issue 23) and long-context attention drift (Issues 22 & 25).
|
||||||
|
- Promoting `deepseek/deepseek-v4.1-flash-1017` to primary across all agents eliminates malformed tool-call stream aborts AND lowers total monthly cost by >50% compared to GLM 5.3 Flash.
|
||||||
|
- **Decoupled Vision Override**: `deepseek/deepseek-v4.1-flash-1017`, `deepseek/deepseek-v4.1-flash`, and `z-ai/glm-5.3-flash` are registered with `input: ["text"]` under `models.providers.openrouter.models` so that OpenClaw always routes user photos through `tools.media.image` (`google/gemini-3.8-flash`) for high-precision German OCR before handing enriched text to the chat model (Issue 29).
|
||||||
* **Fallbacks**:
|
* **Fallbacks**:
|
||||||
1. `openrouter/deepseek/deepseek-v4.1-flash`: Rock-solid JSON tool-calling and sub-3s response times via dedicated OpenRouter ASIC cluster, available immediately if GLM experiences any upstream provider queuing or issues.
|
1. `openrouter/deepseek/deepseek-v4.1-flash` (`0910` baseline across 25+ providers, pinned to `deepinfra`, `streamlake`, `inference-net`, `digitalocean`, `venice`, `nebius`).
|
||||||
2. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
|
2. `openrouter/z-ai/glm-5.3-flash`: Secondary fallback pinned to `fp8`/`bf16` providers (`morph`, `near-ai`, `digitalocean`, `venice`, `streamlake`).
|
||||||
|
3. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
|
||||||
|
|
||||||
### 2. Autonomous Subagents (Tier 2)
|
### 2. Autonomous Subagents (Tier 2)
|
||||||
* **Primary (Active Trial)**: `openrouter/z-ai/glm-5.3-flash`
|
* **All Subagents & Specialists**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
|
||||||
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Inherits the `tool_call` protocol guardrails from `AGENTS.md`.
|
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Provides high-precision reasoning, rock-solid nested JSON tool loops (`tool_search` -> `tool_describe` -> `tool_call`), and zero syntax drift.
|
||||||
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` for high-concurrency tool execution fallback.
|
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/z-ai/glm-5.3-flash` / `openrouter/qwen/qwen3.8-flash`.
|
||||||
|
|
||||||
### 3. Background Daemon & Heartbeats (Tier 3)
|
### 3. Background Daemon & Heartbeats (Tier 3)
|
||||||
* **Configuration**: `heartbeat.model: "openrouter/z-ai/glm-5.3-flash"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
|
* **Configuration**: `heartbeat.model: "openrouter/deepseek/deepseek-v4.1-flash-1017"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
|
||||||
* **Rationale**: Decoupled completely from user conversation transcripts. Polling occurs against an isolated, minimal prompt (~400 tokens), dropping weekly heartbeat token burn from 21,000,000 to <150,000 tokens (<\$0.02 / week).
|
* **Rationale**: Because background heartbeats have a 75%+ `cacheRead` share (`22.86M` `cacheRead` vs. `7.73M` uncached input over 17 days), DeepSeek V4.1 Flash 1017's \$0.00294/M cache read rate on DeepInfra reduces 17-day heartbeat cost to <\$0.90 while keeping prompt cache warm on the primary provider.
|
||||||
|
|
||||||
### 4. Vision Input vs. Text-to-Image Generation (Tier 4)
|
### 4. Vision Input vs. Text-to-Image Generation (Tier 4)
|
||||||
* **Crucial Distinction**:
|
* **Crucial Distinction**:
|
||||||
- **Vision Input (`imageModel`)**: Reading photos sent to Telegram (e.g. food ingredients, plants, receipts). Handled natively by `z-ai/glm-5.3-flash`, with `google/gemini-3.1-flash-image` as fallback.
|
- **Vision Input (`imageModel` vs. Dedicated Primary)**: Reading photos sent to Telegram (e.g. food ingredients, nutrition tables, plants, receipts). Note: OpenClaw only invokes `imageModel` if the primary model *lacks* image input modality. Since `z-ai/glm-5.3-flash` advertises image modality on OpenRouter, OpenClaw does not auto-divert to `imageModel`. Therefore, vision-intensive agents (specifically `koch`) must explicitly configure `primary: "openrouter/google/gemini-3.8-flash"` to leverage Google's SOTA OCR and dense-table recognition.
|
||||||
- **Text-to-Image Synthesis (`mediaModels.image`)**: Creating new pictures upon user request (e.g. *"Zeichne mir ein Profilbild von Octobot"*). Chat LLMs return 404 here.
|
- **Text-to-Image Synthesis (`mediaModels.image`)**: Creating new pictures upon user request (e.g. *"Zeichne mir ein Profilbild von Octobot"*). Chat LLMs return 404 here.
|
||||||
* **Image Synthesis Engine**:
|
* **Image Synthesis Engine**:
|
||||||
- **Primary**: `openrouter/google/gemini-3.1-flash-image` (Nano Banana 2).
|
- **Primary**: `openrouter/google/gemini-3.1-flash-image` (Nano Banana 2).
|
||||||
@@ -169,14 +176,83 @@ To align on remaining edge cases before final sign-off:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## OpenRouter Provider Qualification Criteria & Re-Evaluation Playbook
|
||||||
|
|
||||||
|
Whenever a new model snapshot (such as `deepseek/deepseek-v4.1-flash-1017`) is evaluated or re-checked as new hosting providers join OpenRouter, every candidate provider MUST pass the following **7-point qualification gate** before being added to `extra_body.provider.order` in [openclaw-instance.secret.yaml](file:///Users/moritz/src/infrapuzzle/k8s/openclaw/openclaw-instance.secret.yaml):
|
||||||
|
|
||||||
|
### 1. Hard Technical Gates (Must Pass All)
|
||||||
|
1. **Quantization Precision (`fp8` / `bf16` Only)**:
|
||||||
|
- **Rule**: Must run `fp8` or `bf16` (or verified full-precision `unknown` from trusted cloud hosts like Cloudflare).
|
||||||
|
- **Reject**: Any `fp4`, `nvfp4`, `mxfp4`, or `int4` quantization (`decart`, `sail-research`, `wafer`, `open-inference`, `phala` fp4). 4-bit quantizations severely degrade nested JSON tool-call syntax and German grammar.
|
||||||
|
2. **OpenRouter Routing Funnel & Guardrail Compatibility (`tools` + `tool_choice: "auto"` Pass)**:
|
||||||
|
- **Rule**: Must not be stripped by OpenRouter's internal routing filters (`Filter by Tool Compatibility` or `Filter by Guardrails`) when a request includes `tools: [...]` and `tool_choice: "auto"`.
|
||||||
|
- **Why**: Metadata in `/api/v1/models/.../endpoints` can claim `supports_tool_choice: true` even when OpenRouter's live router blocks the provider on actual tool calls (e.g. `morph/fp8` is stripped by `Filter by Tool Compatibility`, and `deepseek` official is stripped by `Paid model training violation` account privacy settings).
|
||||||
|
3. **Nested MCP `tool_call` Serialization Integrity (2/2 Live Pass)**:
|
||||||
|
- **Rule**: Must cleanly emit `finish_reason: "tool_calls"` with valid, parseable nested JSON arguments (`{"id": "mcp:...", "args": {...}}`) across back-to-back test runs without mid-stream SSE aborts (`Provider returned an incomplete or malformed tool call`) or HTTP `429` upstream rate limits.
|
||||||
|
4. **Context Window & Completion Headroom**:
|
||||||
|
- **Rule**: `context_length >= 262,144` (1,048,576 preferred) and `max_completion_tokens >= 131,072`.
|
||||||
|
- **Why**: Active daytime sessions with system prompts and tool schemas reach 20k–80k tokens.
|
||||||
|
5. **Uptime & Reliability**:
|
||||||
|
- **Rule**: `uptime_last_1d >= 95%` and `uptime_last_30m == 100%`.
|
||||||
|
|
||||||
|
### 2. Economic & Latency Ranking (How to Order Passing Providers)
|
||||||
|
Among providers passing all 5 technical gates, rank them in `extra_body.provider.order` by **effective production cost** and **turn latency**:
|
||||||
|
1. **Implicit Prompt Cache Read Rate (`input_cache_read`) — Highest Weight**:
|
||||||
|
- Because **73%–94%** of OpenClaw's token volume (both interactive multi-turn chats and 30m background heartbeats) consists of cached prompt reads (`cacheRead`), a provider with `supports_implicit_caching: true` and a **95%–98% cache discount** (`<= $0.005 / 1M` tokens, e.g. `deepinfra` at `$0.00294/M` or `streamlake` at `$0.0030/M`) is vastly cheaper in net monthly spend than a provider with slightly cheaper uncached input but weak or missing `cacheRead` discounts (e.g. `nebius` with `cache_read: null` or `modal`/`primeintellect` at `$0.030/M`).
|
||||||
|
2. **Base Prompt & Completion Pricing**:
|
||||||
|
- Target `prompt <= $0.18 / 1M` and `completion <= $0.65 / 1M` for top-priority slots; allow higher-priced providers (`<= $0.30 / $1.20`) only in lower fallback slots (#3/#4) for outage resilience.
|
||||||
|
3. **End-to-End Tool-Call Latency**:
|
||||||
|
- Target **1.0s – 5.5s** for a ~300-token reasoning + tool-call turn.
|
||||||
|
|
||||||
|
### 3. One-Shot Live Verification Command (For Future Checks)
|
||||||
|
Any agent (including a fast/lightweight model) can audit all currently available OpenRouter endpoints for `deepseek/deepseek-v4.1-flash-1017` in one step by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Inspect current endpoint metadata, pricing, cache_read, and 24h uptime:
|
||||||
|
curl -s https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints | \
|
||||||
|
jq -r '.data.endpoints[] | "\(.provider_name) | tag=\(.tag) | quant=\(.quantization) | ctx=\(.context_length) | prompt=\(.pricing.prompt) | comp=\(.pricing.completion) | cache=\(.pricing.input_cache_read) | uptime1d=\(.uptime_last_1d)"'
|
||||||
|
|
||||||
|
# 2. Live-test candidate providers inside openclaw-0 with real nested tool_call payloads:
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c '
|
||||||
|
import os, json, time, urllib.request
|
||||||
|
api_key = os.environ["OPENROUTER_API_KEY"]
|
||||||
|
tools = [{"type": "function", "function": {"name": "tool_call", "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "args": {"type": "object"}}, "required": ["id", "args"]}}}]
|
||||||
|
candidates = ["deepinfra", "streamlake", "inference-net", "venice", "digitalocean", "novita", "parasail", "cloudflare", "nebius"]
|
||||||
|
for prov in candidates:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
data=json.dumps({
|
||||||
|
"model": "deepseek/deepseek-v4.1-flash-1017",
|
||||||
|
"messages": [{"role": "user", "content": "Call tool_call with id=\"mcp:homeassistant:homeassistant__ha_get_state\" and args={\"entity_id\": \"media_player.bad\"}"}],
|
||||||
|
"tools": tools, "tool_choice": "auto", "max_tokens": 250,
|
||||||
|
"provider": {"order": [prov], "allow_fallbacks": False}
|
||||||
|
}).encode(),
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
b = json.loads(r.read().decode())
|
||||||
|
tc = b["choices"][0]["message"].get("tool_calls")
|
||||||
|
args = tc[0]["function"]["arguments"] if tc else "NONE"
|
||||||
|
print(f"PASS | {prov:15s} | {time.time()-t0:4.2f}s | actual={b.get(\"provider\")} | args={args}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"FAIL | {prov:15s} | HTTP {e.code} | {e.read().decode()[:140]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL | {prov:15s} | ERR {e}")
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### Positive
|
||||||
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on Gemini 3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
|
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
|
||||||
* **100% Reliable Image Generation**: Correctly routes image generation requests to Nano Banana 2 (`google/gemini-3.1-flash-image`), completely eliminating HTTP 404 generation errors.
|
* **100% Reliable Image Generation**: Correctly routes image generation requests to Nano Banana 2 (`google/gemini-3.1-flash-image`), completely eliminating HTTP 404 generation errors.
|
||||||
* **Cohesive Voice & Audio Pipeline**: Clear architectural documentation of the Whisper V3 Turbo and Gemini 3.1 Flash TTS integration for dossiers and podcasts.
|
* **Cohesive Voice & Audio Pipeline**: Clear architectural documentation of the Whisper V3 Turbo and Gemini 3.1 Flash TTS integration for dossiers and podcasts.
|
||||||
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$1.20–\$1.80** (85–90% savings) through decoupled heartbeats and nightly compaction.
|
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$0.80–\$1.40** (~90% savings) through DeepSeek V4.1 Flash 1017's `$0.00294/M` cache reads, decoupled heartbeats, and nightly compaction.
|
||||||
|
|
||||||
### Negative & Mitigations
|
### Negative & Mitigations
|
||||||
* **OpenRouter Provider Variability**: GLM-5.3-Flash prefill throughput varies across OpenRouter hosting providers.
|
* **OpenRouter Provider Rollout Lag on Day-0 Model Releases**: Newly released snapshots initially launch with 5–10 providers before the full fleet (25+ providers) joins over subsequent days.
|
||||||
* *Mitigation*: Daily compaction bounds transcript size; failover to DeepSeek V4.1-Flash is available at zero architectural switching cost.
|
* *Mitigation*: Codified the 7-point provider qualification gate and one-shot test script above, and configured automatic model fallback to the `0910` snapshot (`openrouter/deepseek/deepseek-v4.1-flash`).
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ Chosen Option: **Option 3: Comprehensive Everything-as-Code with Pragmatic State
|
|||||||
- Cluster IP mapping to the bare-metal ingress endpoint.
|
- Cluster IP mapping to the bare-metal ingress endpoint.
|
||||||
* **Execution**: Fully automated via `terraform plan` / `terraform apply` directly from version-controlled state.
|
* **Execution**: Fully automated via `terraform plan` / `terraform apply` directly from version-controlled state.
|
||||||
|
|
||||||
#### Tier 4: Smart Home & IoT Edge Hardware (`terraform_shelly/`, `esphome/`, `monitoring/`)
|
#### Tier 4: Smart Home & IoT Edge Hardware (`projects/shelly_taupunktluefter/`, `esphome/`, `monitoring/`)
|
||||||
* **Tooling**: ESPHome, Shelly Terraform Provider, YAML configs.
|
* **Tooling**: ESPHome, Shelly Terraform Provider, YAML configs.
|
||||||
* **Scope**:
|
* **Scope**:
|
||||||
- Shelly relays and smart switches provisioned declaratively via Terraform.
|
- Shelly relays and smart switches provisioned declaratively via Terraform.
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
status: "accepted"
|
||||||
|
date: 2026-10-06
|
||||||
|
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||||
|
consulted: []
|
||||||
|
informed: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR 0004: Structured Personal Data Layer (DuckDB + JSONL in Knowledge Wiki)
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
OpenClaw manages personal logging and family assistance for the Graf family across several daily domains:
|
||||||
|
1. **Personal Nutrition:** Daily food intake, macro tracking (protein, carbs, sugar, fat, fiber, salt, calories) in `entities/nutrition-log/moritz/*.md`.
|
||||||
|
2. **Baby Sleep & Routines:** Sleep logs, naps, night sleep, bottle feeds, and developmental milestones for Mila in `entities/people/mila-schlafprotokoll.md`.
|
||||||
|
3. **Sports & Physiological Balance:** Activities, daily calorie expenditure (RMR + active), and net caloric surplus/deficit synced with Garmin Connect.
|
||||||
|
4. **Culinary Knowledge:** Recipes with ingredient proportions, cooking steps, and hardware settings.
|
||||||
|
5. **Points of Interest (POIs):** Local stores, farm stands (Hofläden), beer gardens, and playgrounds in Regensburg.
|
||||||
|
|
||||||
|
Historically, this data was logged directly into Markdown tables and bulleted lists in the Knowledge Wiki (`knowledge-wiki/`). An analysis of existing log files revealed severe systemic failure modes:
|
||||||
|
- **LLM Arithmetic Drift:** In `2026-09.md` and `2026-10.md`, the LLM consistently failed at daily macro sums (errors up to 75 kcal and 7.5 g protein per day). The derived "Vortags-Bilanz" (caloric deficit/surplus against Garmin) was calculated from incorrect totals.
|
||||||
|
- **Structural and Chronological Degradation:** In `mila-schlafprotokoll.md`, dates drifted out of order (`29.09 → Sep Summary → 30.09 → 01.10 → 03.10 → 02.10 → 28.09`), and every night sleep was duplicated (once in the evening, once the next morning).
|
||||||
|
- **Inability to Perform Spatial & Aggregate Queries:** Questions like *"What was Mila's average night sleep duration over the last 14 days?"* or *"Which beer gardens are within 500m of Grabengasse 7?"* required either multi-turn LLM hallucination or reading hundreds of lines of raw text.
|
||||||
|
|
||||||
|
How do we establish a structured, reliable, and queryable data layer for OpenClaw that guarantees mathematical precision while upholding our Everything-as-Code (ADR 0003) and Git-as-Source-of-Truth architecture?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
* **Mathematical Precision:** Zero LLM arithmetic. All summations, caloric balances, sleep durations, and nutrient rollups must be computed by a deterministic SQL engine.
|
||||||
|
* **Git as the Single Source of Truth (ADR 0003):** No external database silos (PostgreSQL, standalone SQLite PVCs) that can drift from the git-backed wiki. All state must remain git-versioned, diffable, and merge-friendly.
|
||||||
|
* **Separation of Concerns (Math vs. Narrative):** Delegate structured math, joins, and coordinates to DuckDB. Delegate natural-language understanding, nuance, packaging photo interpretation, and cooking advice to the LLM.
|
||||||
|
* **Flexible Composability:** Foods can become ingredients in recipes, which can yield composite foods (e.g. wheat → baking mix → bread; tomatoes → sauce → pizza).
|
||||||
|
* **Multi-Channel Presentation:** Auto-updated Markdown files for SilverBullet (`wiki.moritzgraf.de`), Telegram inline text with `chart-to-chat` PNGs, and mobile-friendly HTML dashboards on `hosting.haumdaucher.de/family/`.
|
||||||
|
* **Zero-Tax Execution:** In-memory query execution in Python via `uv` without adding heavy runtime containers or pod memory overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
1. **Option 1: Stricter Markdown Prompts + Python Lint Rules**
|
||||||
|
* Keep Markdown tables; add linter checks in `okf_lint.py` to flag wrong sums.
|
||||||
|
* *Drawback*: Reactive detection rather than proactive prevention. Cannot do spatial searches or aggregate SQL queries.
|
||||||
|
|
||||||
|
2. **Option 2: External Relational Database (PostgreSQL / PVC SQLite)**
|
||||||
|
* Deploy PostgreSQL or write to a binary SQLite database on the Kubernetes PVC.
|
||||||
|
* *Drawback*: Violates ADR 0003. Creates a separate data silo outside git, introduces backup synchronization risks, and binary files cannot be cleanly merged or diffed in Git.
|
||||||
|
|
||||||
|
3. **Option 3: Pure Event Sourcing for All Data**
|
||||||
|
* Force every piece of data (including stores, recipes, and food labels) into an immutable event log.
|
||||||
|
* *Drawback*: Over-engineers static catalogs. Catalog entities (like places or recipes) become awkward to manage and query when forced into point-in-time event streams.
|
||||||
|
|
||||||
|
4. **Option 4: Three-Tier Archetype Architecture via DuckDB & JSONL in Git (Selected)**
|
||||||
|
* Store structured records in line-delimited JSON (`.jsonl`) inside the Knowledge Wiki repository (`data/`), using in-memory DuckDB as the calculation and query engine.
|
||||||
|
* Divide data into three natural archetypes: **Events**, **Entities**, and **Documents**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen Option: **Option 4: Three-Tier Archetype Architecture via DuckDB & JSONL in Git**.
|
||||||
|
|
||||||
|
### 1. The Three Data Archetypes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Three Data Archetypes │
|
||||||
|
├─────────────────────────┬─────────────────────────┬────────────────────┤
|
||||||
|
│ 1. EVENTS (Time-Series) │ 2. ENTITIES (Catalogs) │ 3. DOCUMENTS (Prose│
|
||||||
|
├─────────────────────────┼─────────────────────────┼────────────────────┤
|
||||||
|
│ • data/events/nutrition/│ • data/entities/foods/ │ • entities/recipes/│
|
||||||
|
│ • data/events/sleep/ │ • data/entities/recipes/│ • syntheses/places/│
|
||||||
|
│ • data/events/feeding/ │ • data/entities/places/ │ • sources/*.pdf │
|
||||||
|
│ • data/events/body/ │ • data/entities/people/ │ │
|
||||||
|
│ [Point & Interval Logs] │ [Durable Slug Identifiers] [Markdown / Media]│
|
||||||
|
└────────────┬────────────┴────────────┬────────────┴──────────┬─────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ │
|
||||||
|
┌──────────────────────────────────────────────┐ │
|
||||||
|
│ In-Memory DuckDB SQL Engine │ │
|
||||||
|
│ • Schema Validation (STRUCT & CHECK) │ │
|
||||||
|
│ • SQL Aggregations & Daily Deficit Joins │ │
|
||||||
|
│ • Spatial Queries (ST_DWithin / Haversine) │ │
|
||||||
|
└──────────────────────┬───────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
┌────────────┴────────────┐ │
|
||||||
|
▼ ▼ │
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ Telegram Output │ │ Generated Views │ │
|
||||||
|
│ • Text Summaries │ │ • Markdown Views ◄────────────┘
|
||||||
|
│ • chart-to-chat │ │ • HTML Dashboards│ (Frontmatter:
|
||||||
|
└──────────────────┘ │ (/family/...) │ datastore_ref)
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Events (Time-Series Logs):**
|
||||||
|
- Stored in `data/events/<domain>/<YYYY-MM>.jsonl`, sorted by `(start, id)`.
|
||||||
|
- Unified envelope: `id` (ULID), `kind`, `subject`, `actor`, `start`, `end` (supports open intervals where `end=NULL`), `estimated`, `meal`, `note`, `payload` (JSON cast to typed STRUCT), `source_ref`.
|
||||||
|
2. **Entities (Structured Catalogs):**
|
||||||
|
- Stored in `data/entities/<collection>.jsonl`, sorted by slug `id`.
|
||||||
|
- `foods`: EU LMIV per-100g nutritional profile, ingredient lists, barcodes, density.
|
||||||
|
- `recipes`: Ingredients list (linking `foods` with exact grams or raw text), servings, calculated macros.
|
||||||
|
- `places`: Points of interest with `lat`, `lon`, category, address, tags, and linked documents.
|
||||||
|
- `people`: Family profile registry.
|
||||||
|
3. **Documents (Prose & Media):**
|
||||||
|
- Stored as standard Markdown in `knowledge-wiki/`.
|
||||||
|
- Deterministic two-way link: Document YAML frontmatter contains `datastore_ref: "<collection>:<id>"`, while the entity JSONL record lists `documents: ["path/to/doc.md"]`.
|
||||||
|
|
||||||
|
### 2. Dedicated Raw Garmin Mirror (`data/garmin/`)
|
||||||
|
- Nightly zero-LLM Python sync script fetches raw Garmin API responses directly into:
|
||||||
|
- `data/garmin/activities/<YYYY-MM>.jsonl`
|
||||||
|
- `data/garmin/daily/<YYYY-MM>.jsonl`
|
||||||
|
- `data/garmin/sleep/<YYYY-MM>.jsonl`
|
||||||
|
- DuckDB queries raw JSON directly with `read_json_auto()` and computes the daily caloric deficit/surplus by joining nutrition intake with Garmin total daily energy expenditure.
|
||||||
|
|
||||||
|
### 3. Spatial & Geospatial Radius Lookups
|
||||||
|
- DuckDB attempts to initialize the official `spatial` extension (`LOAD spatial;`).
|
||||||
|
- If offline or binary installation fails, it transparently falls back to an internal pure-SQL Haversine macro `distance_m(lat1, lon1, lat2, lon2)`, ensuring zero-failure execution across any environment.
|
||||||
|
|
||||||
|
### 4. Privacy & Hosting Routes (`hosting.haumdaucher.de`)
|
||||||
|
- **Public (`/recipes/*`, `/places/*`, `/public/*`):** Publicly accessible recipe cards and shareable dossiers.
|
||||||
|
- **Family (`/family/*`):** Protected by Traefik ForwardAuth linked to Authentik group `family` (nutrition dashboards, baby sleep charts).
|
||||||
|
- **Private (`/private/*`):** Protected by Authentik for personal vital logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- **Guaranteed Calculation Accuracy:** All calories, macronutrient totals, sleep statistics, and energy balances are computed in SQL. Zero LLM math errors.
|
||||||
|
- **Git-Native & Mergable:** Text-based JSONL files partition data cleanly and produce clean 1-line git diffs.
|
||||||
|
- **Offline & Low-Latency:** In-memory DuckDB reads months of JSONL in single-digit milliseconds.
|
||||||
|
- **Preserved Human Readability:** SilverBullet (`wiki.moritzgraf.de`) and Obsidian continue to display updated Markdown tables generated directly from the database.
|
||||||
|
|
||||||
|
### Negative & Mitigations
|
||||||
|
- **Generated Markdown Views Must Not Be Hand-Edited:**
|
||||||
|
- *Mitigation*: Generated files include a prominent `<!-- DO NOT EDIT: GENERATED BY DATASTORE -->` warning comment.
|
||||||
|
- *Mitigation*: Git pre-commit hook and the weekly IT Guy audit verify that generated views match the underlying JSONL data.
|
||||||
|
- **Dual-Step Agent Retrieval:** Agents execute `datastore query` for structured data, then `wiki_get` on linked documents if full narrative text is needed.
|
||||||
|
- *Mitigation*: Clear instructions and query helpers in the `datastore` skill prompt.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
status: "accepted"
|
||||||
|
date: 2026-10-11
|
||||||
|
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||||
|
consulted: []
|
||||||
|
informed: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR 0005: Personal Data Isolation, Authentik Authentication Boundaries, and Hosting Segregation
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
On 2026-10-11, during the development of mobile-first PWA dashboards for baby monitoring (Mila Graf) and personal nutrition/Garmin energy balance (Moritz Graf), personal health, sleep, diaper, and nutrition data was temporarily made available at unauthenticated root URLs:
|
||||||
|
- `https://hosting.haumdaucher.de/mila.html`
|
||||||
|
- `https://hosting.haumdaucher.de/moritz.html`
|
||||||
|
|
||||||
|
While [ADR 0004](0004-structured-personal-data-datastore.md) established the conceptual routing design (`/family/*` protected by Traefik ForwardAuth, public recipes at `/recipes/*`), a gap analysis revealed critical systemic vulnerabilities:
|
||||||
|
1. **Unapplied Kubernetes Ingress:** The ForwardAuth Ingress (`minio-hosting-family`) declared in `k8s/minio/hosting-ingress.yaml` was never applied to the Kubernetes cluster.
|
||||||
|
2. **Missing Authentik Identity Provider:** Authentik had no Proxy Provider or Application configured for `hosting.haumdaucher.de`, returning HTTP 404 on ForwardAuth checks.
|
||||||
|
3. **Dual-Publishing Anti-Pattern:** Dashboard generator scripts (`update_website.py`, `update_moritz_website.py`) mirrored files to root (`/mila.html`, `/moritz.html`) for convenience, completely bypassing the planned `/family/` path boundary.
|
||||||
|
4. **Lack of Automated Ingress & Storage Auditing:** No automated test or CI/Cron audit prevented personal data from being uploaded to public prefixes.
|
||||||
|
|
||||||
|
How do we establish a permanent, verifiable, and automated security boundary that guarantees personal and family data can never be exposed publicly without authentication?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
* **Zero Public Exposure for Personal Data:** Health, sleep, nutrition, body, and family data must NEVER be accessible without authentication.
|
||||||
|
* **Defense-in-Depth:** Security must not rely on a single layer (e.g. secret URLs or developer memory). Protection must be enforced at identity provider, reverse proxy, storage bucket, and generator script levels.
|
||||||
|
* **Declarative Infrastructure as Code (ADR 0003):** All Authentik providers, policies, groups, and Kubernetes Ingresses must be versioned in Git and applied via Terraform/Kubectl.
|
||||||
|
* **Automated Guardrails & Fast Feedback:** Continuous audits must detect and quarantine any unauthorized files outside protected paths.
|
||||||
|
* **Seamless PWA Experience:** The PWA capabilities (manifest, service worker, offline shell) must function within the authenticated cookie session without breaking ForwardAuth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen Strategy: **Multi-Layer Boundary Enforcement with ForwardAuth, Strict Storage Segregation, and Continuous Auditing**.
|
||||||
|
|
||||||
|
### 1. Storage & URL Path Segmentation Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet Request to https://hosting.haumdaucher.de
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────┐
|
||||||
|
│ Traefik Ingress │
|
||||||
|
└─────────┬─────────┘
|
||||||
|
│
|
||||||
|
Path Inspection & Priority
|
||||||
|
│
|
||||||
|
┌────────────────┴────────────────┐
|
||||||
|
│ │
|
||||||
|
Path: /family/* Path: /* (Fallback)
|
||||||
|
Priority: 100 Priority: 10
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||||
|
│ Authentik ForwardAuth │ │ Public Ingress │
|
||||||
|
│ • Host: hosting.haumd...│ │ • ONLY /recipes/* │
|
||||||
|
│ • Allowed: 'family' & │ │ • ONLY /showcase/* │
|
||||||
|
│ 'infra-admins' groups │ │ • NO personal files │
|
||||||
|
└────────────┬────────────┘ └────────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
HTTP 200 (Authed) │
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ MinIO Bucket │ │ MinIO Bucket │
|
||||||
|
│ key: family/* │ │ key: public/* │
|
||||||
|
│ (Protected Data) │ │ (Public Static) │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Rule 1: Canonical Protected Prefix `/family/`
|
||||||
|
- All dashboards containing personal, familial, or physiological metrics MUST reside under `/family/`:
|
||||||
|
- `https://hosting.haumdaucher.de/family/mila.html` (Baby sleep, feeds, diapers)
|
||||||
|
- `https://hosting.haumdaucher.de/family/moritz.html` (Personal nutrition, Garmin deficits, health)
|
||||||
|
- `https://hosting.haumdaucher.de/family/nutrition.html` (Family nutrition overview)
|
||||||
|
- `https://hosting.haumdaucher.de/family/sleep.html` (Sleep analytics)
|
||||||
|
- Associated PWA assets (manifests, service workers) MUST also be scoped within `/family/` (e.g. `/family/mila.webmanifest`, `/family/sw.js`).
|
||||||
|
|
||||||
|
#### Rule 2: Root Prefix `/` Strictly Restricted
|
||||||
|
- The root of `hosting.haumdaucher.de` is restricted to public static assets (`/recipes/*`, `/showcase/*`).
|
||||||
|
- Publishing scripts MUST NEVER write personal dashboards to the bucket root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Declarative Authentik ForwardAuth Configuration
|
||||||
|
|
||||||
|
Authentik configuration is codified in `terraform/authentik.tf`:
|
||||||
|
1. **Proxy Provider (`authentik_provider_proxy.hosting_family`):**
|
||||||
|
- External Host: `https://hosting.haumdaucher.de`
|
||||||
|
- Mode: `forward_single`
|
||||||
|
- Bound to `authentik_outpost.embedded`
|
||||||
|
- Session duration: 30 days (`access_token_validity = "days=30"`)
|
||||||
|
2. **Application (`authentik_application.hosting_family`):**
|
||||||
|
- Slug: `hosting-family`
|
||||||
|
- Protocol Provider: `authentik_provider_proxy.hosting_family.id`
|
||||||
|
3. **Role-Based Policy Bindings:**
|
||||||
|
- Group `family` (Moritz & Amelie Graf): Granted access
|
||||||
|
- Group `infra-admins`: Granted access
|
||||||
|
- Unauthenticated requests are redirected (HTTP 302) to `https://auth.haumdaucher.de/application/o/authorize/...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Kubernetes Ingress Deployment
|
||||||
|
|
||||||
|
`k8s/minio/hosting-ingress.yaml` defines the two ingress tiers:
|
||||||
|
- **`minio-hosting-family` (Priority 100):**
|
||||||
|
- Paths:
|
||||||
|
- `/family` (Prefix) -> Protected personal dashboards
|
||||||
|
- `/outpost.goauthentik.io` (Prefix) -> Authentik ForwardAuth OAuth callback & session negotiation
|
||||||
|
- Middlewares: `auth-authentik-forwardauth@kubernetescrd,minio-hosting-add-prefix@kubernetescrd`
|
||||||
|
- **`minio-hosting` (Priority 10):**
|
||||||
|
- Path: `/` (Prefix)
|
||||||
|
- Middlewares: `minio-hosting-add-prefix@kubernetescrd`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Generator Scripts & Codebase Hardening
|
||||||
|
|
||||||
|
1. **Elimination of Root Duplication:**
|
||||||
|
- `skills/datastore/scripts/update_website.py`: Removed all S3 upload and local sync calls to root `mila.html`. Target is strictly `family/mila.html`.
|
||||||
|
- `skills/datastore/scripts/update_moritz_website.py`: Removed S3 upload and local sync calls to root `moritz.html`. Target is strictly `family/moritz.html` (and `family/nutrition.html`).
|
||||||
|
2. **PWA Scope Alignment:**
|
||||||
|
- Web App Manifests configure `start_url: "/family/mila.html"` and `scope: "/family/"`.
|
||||||
|
- Service workers are registered at `/family/sw.js`.
|
||||||
|
3. **Telegram Link Consistency:**
|
||||||
|
- Agent prompts for `mila` (Topic 20) and `koch` (Topic 21) only broadcast the secure URL: `https://hosting.haumdaucher.de/family/...`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Automated Continuous Leak Audit Guard
|
||||||
|
|
||||||
|
An automated audit check runs continuously (via OpenClaw periodic audit and datastore maintenance):
|
||||||
|
- Inspects the MinIO `hosting` bucket root (`prefix=""`).
|
||||||
|
- Detects any forbidden files matching patterns: `*mila*`, `*moritz*`, `*nutrition*`, `*sleep*`, `*schlaf*`, `*health*`, `*diaper*`.
|
||||||
|
- If detected:
|
||||||
|
1. Immediately deletes/quarantines the offending object from the public bucket root.
|
||||||
|
2. Raises a critical notification/event to the system log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- **Guaranteed Confidentiality:** Family and personal health data is physically impossible to access without passing Authentik ForwardAuth.
|
||||||
|
- **Reproducible Infrastructure:** The entire authentication chain is codified in Terraform and Kubernetes manifests.
|
||||||
|
- **Persistent Mobile PWA:** Authentik's 30-day session lifetime ensures seamless standalone mobile app operation without repetitive login prompts.
|
||||||
|
- **Automated Safety Net:** Even if a developer or agent script mistakenly attempts to mirror a file to root, the automated audit deletes it.
|
||||||
|
|
||||||
|
### Negative & Mitigations
|
||||||
|
- **Authentication Step on First Mobile Launch:**
|
||||||
|
- *Mitigation*: When installing the PWA on iOS/Android, the user authenticates once via Authentik. The persistent session cookie maintains login across app launches.
|
||||||
@@ -13,6 +13,8 @@ We follow the [Markdown Architectural Decision Records (MADR)](https://adr.githu
|
|||||||
| [**0001**](0001-hybrid-mcp-tool-search-and-subagents.md) | **Hybrid MCP Tool Search and Subagent Delegation for AI Agents** | `accepted` | 2026-09-12 | OpenClaw / AI / MCP |
|
| [**0001**](0001-hybrid-mcp-tool-search-and-subagents.md) | **Hybrid MCP Tool Search and Subagent Delegation for AI Agents** | `accepted` | 2026-09-12 | OpenClaw / AI / MCP |
|
||||||
| [**0002**](0002-openclaw-model-selection-tiering-and-economics.md) | **OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics** | `proposed` | 2026-09-13 | OpenClaw / AI / Models |
|
| [**0002**](0002-openclaw-model-selection-tiering-and-economics.md) | **OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics** | `proposed` | 2026-09-13 | OpenClaw / AI / Models |
|
||||||
| [**0003**](0003-everything-as-code-and-reproducible-infrastructure.md) | **Everything as Code (EaC) and Reproducible Declarative Infrastructure** | `accepted` | 2026-09-17 | Architecture / Monorepo / EaC |
|
| [**0003**](0003-everything-as-code-and-reproducible-infrastructure.md) | **Everything as Code (EaC) and Reproducible Declarative Infrastructure** | `accepted` | 2026-09-17 | Architecture / Monorepo / EaC |
|
||||||
|
| [**0004**](0004-structured-personal-data-datastore.md) | **Structured Personal Data Layer (DuckDB + JSONL in Knowledge Wiki)** | `accepted` | 2026-10-06 | OpenClaw / Data / Storage |
|
||||||
|
| [**0005**](0005-personal-data-isolation-and-authentik-boundary.md) | **Personal Data Isolation, Authentik Authentication Boundaries, and Hosting Segregation** | `accepted` | 2026-10-11 | Security / Storage / Authentik |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# AGENTS.md (ESPHome Workspace)
|
|
||||||
|
|
||||||
This directory is a self-contained ESPHome project space utilizing dynamic configuration loaders and an interactive flashing CLI.
|
|
||||||
|
|
||||||
## Rules & Design Standards
|
|
||||||
|
|
||||||
### Secret Management
|
|
||||||
* **NEVER** write plaintext Wi-Fi passwords, tokens, or API keys directly in the YAML configurations.
|
|
||||||
* **DO NOT** modify the `.gitignore` to allow checking in `.env` files.
|
|
||||||
* Use ESPHome's `!env_var VAR_NAME` to inject environment variables at compile-time. Any new secrets must be declared in `.env.template` and configured locally in `.env`.
|
|
||||||
|
|
||||||
### Configuration Standards
|
|
||||||
* Default board architecture is ESP32 (`nodemcu-32s` with `arduino` framework) unless specified.
|
|
||||||
* Naming conventions for configurations: `esdisplay_<name>.yaml` or `sensor_<name>.yaml`.
|
|
||||||
* All displays should use the `time` component with SNTP sync for accurate time rendering.
|
|
||||||
* Prefer importing sensors from Home Assistant over Native API client integrations, as it decouples rendering layouts from automation scripts.
|
|
||||||
|
|
||||||
### Build and Run Tools
|
|
||||||
* Use `uv run flash.py` to compile, validate, clean, or flash ESPHome configurations.
|
|
||||||
* Avoid triggering direct `esphome` cli calls unless doing isolated validation tests.
|
|
||||||
+3
-2
@@ -11,8 +11,9 @@ This directory contains the Kubernetes manifests and Helm charts for a single-no
|
|||||||
* **SSL**: `cert-manager` (LetsEncrypt)
|
* **SSL**: `cert-manager` (LetsEncrypt)
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
* **Top-level folders**: Each folder corresponds to a Kubernetes **namespace**.
|
* **Strict Namespace Mapping**: **Every top-level subdirectory in `k8s/` MUST correspond strictly to a Kubernetes namespace.**
|
||||||
* Example: `mailu/` contains resources for the `mailu` namespace.
|
* Example: `mailu/` contains resources for the `mailu` namespace, `grabengasse/` for `grabengasse`, `haumdaucher/` for `haumdaucher`, etc.
|
||||||
|
* **NO non-namespace folders**: Loose utility scripts belong in the root `scripts/` directory, application source code belongs in `projects/`, and general documentation belongs in namespace READMEs or `docs/`.
|
||||||
* **Documentation**: `README.md` is the **authoritative source** for deployment commands. Always check it before running commands.
|
* **Documentation**: `README.md` is the **authoritative source** for deployment commands. Always check it before running commands.
|
||||||
|
|
||||||
## Code Style & Conventions
|
## Code Style & Conventions
|
||||||
|
|||||||
+6
-6
@@ -873,13 +873,13 @@ Grabengasse 7 home website and backend service deployed in the `grabengasse` nam
|
|||||||
# Login to local registry (if not already logged in)
|
# Login to local registry (if not already logged in)
|
||||||
docker login registry.haumdaucher.de -u moritz -p "PPf10musSh!!"
|
docker login registry.haumdaucher.de -u moritz -p "PPf10musSh!!"
|
||||||
|
|
||||||
# Build and push backend image
|
# Build and push images via root projects/grabengasse Makefile
|
||||||
cd website/grabengasse_backend
|
cd projects/grabengasse
|
||||||
make all
|
make build push
|
||||||
|
|
||||||
# Build and push frontend image
|
# Or build individual components:
|
||||||
cd ../grabengasse_frontend
|
cd projects/grabengasse/frontend && make all
|
||||||
make all
|
cd ../backend && make all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to Kubernetes
|
### Deploy to Kubernetes
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: corona-api
|
|
||||||
labels:
|
|
||||||
app: corona-api
|
|
||||||
namespace: datalab
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: corona-api
|
|
||||||
replicas: 1
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: corona-api
|
|
||||||
spec:
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: "registry-haumdaucher-de"
|
|
||||||
containers:
|
|
||||||
- image: registry.haumdaucher.de/corona_plumber:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
name: corona-api
|
|
||||||
ports:
|
|
||||||
- containerPort: 8000
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: corona-api
|
|
||||||
namespace: datalab
|
|
||||||
spec:
|
|
||||||
ports:
|
|
||||||
- port: 8000
|
|
||||||
targetPort: 8000
|
|
||||||
protocol: TCP
|
|
||||||
selector:
|
|
||||||
app: corona-api
|
|
||||||
---
|
|
||||||
apiVersion: extensions/v1beta1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: corona-api-haumdaucher
|
|
||||||
namespace: datalab
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/ingress.class: "nginx"
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "corona-api.haumdaucher.de"
|
|
||||||
secretName: corona-api-haumdaucher-de
|
|
||||||
rules:
|
|
||||||
- host: corona-api.haumdaucher.de
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
backend:
|
|
||||||
serviceName: corona-api
|
|
||||||
servicePort: 8000
|
|
||||||
---
|
|
||||||
apiVersion: extensions/v1beta1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: corona-api-moritzgraf
|
|
||||||
namespace: datalab
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/ingress.class: "nginx"
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "corona-api.moritzgraf.de"
|
|
||||||
secretName: corona-api-moritzgraf-de
|
|
||||||
rules:
|
|
||||||
- host: corona-api.moritzgraf.de
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
backend:
|
|
||||||
serviceName: corona-api
|
|
||||||
servicePort: 8000
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: corona
|
|
||||||
labels:
|
|
||||||
app: corona
|
|
||||||
namespace: datalab
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: corona
|
|
||||||
replicas: 1
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: corona
|
|
||||||
spec:
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: "registry-haumdaucher-de"
|
|
||||||
containers:
|
|
||||||
- image: registry.haumdaucher.de/corona:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
name: corona
|
|
||||||
ports:
|
|
||||||
- containerPort: 3838
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: corona
|
|
||||||
namespace: datalab
|
|
||||||
spec:
|
|
||||||
ports:
|
|
||||||
- port: 3838
|
|
||||||
targetPort: 3838
|
|
||||||
protocol: TCP
|
|
||||||
selector:
|
|
||||||
app: corona
|
|
||||||
---
|
|
||||||
apiVersion: extensions/v1beta1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: corona-haumdaucher
|
|
||||||
namespace: datalab
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/ingress.class: "nginx"
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "corona.haumdaucher.de"
|
|
||||||
secretName: corona-haumdaucher-de
|
|
||||||
rules:
|
|
||||||
- host: corona.haumdaucher.de
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
backend:
|
|
||||||
serviceName: corona
|
|
||||||
servicePort: 3838
|
|
||||||
---
|
|
||||||
apiVersion: extensions/v1beta1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: corona-moritzgraf
|
|
||||||
namespace: datalab
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/ingress.class: "nginx"
|
|
||||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "corona.moritzgraf.de"
|
|
||||||
secretName: corona-moritzgraf-de
|
|
||||||
rules:
|
|
||||||
- host: corona.moritzgraf.de
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
backend:
|
|
||||||
serviceName: corona
|
|
||||||
servicePort: 3838
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
creationTimestamp: null
|
|
||||||
name: redeploy
|
|
||||||
namespace: datalab
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: Role
|
|
||||||
metadata:
|
|
||||||
namespace: datalab
|
|
||||||
name: redeploy
|
|
||||||
rules:
|
|
||||||
- apiGroups: ["apps"]
|
|
||||||
resources: ["deployments"]
|
|
||||||
verbs: ["get",list","patch"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
# This role binding allows "jane" to read pods in the "default" namespace.
|
|
||||||
# You need to already have a Role named "pod-reader" in that namespace.
|
|
||||||
kind: RoleBinding
|
|
||||||
metadata:
|
|
||||||
name: redeploy-pods
|
|
||||||
namespace: datalab
|
|
||||||
subjects:
|
|
||||||
# You can specify more than one "subject"
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: redeploy
|
|
||||||
roleRef:
|
|
||||||
# "roleRef" specifies the binding to a Role / ClusterRole
|
|
||||||
kind: Role #this must be Role or ClusterRole
|
|
||||||
name: redeploy
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: batch/v1beta1
|
|
||||||
kind: CronJob
|
|
||||||
metadata:
|
|
||||||
name: redeploy-corona-api
|
|
||||||
namespace: datalab
|
|
||||||
spec:
|
|
||||||
schedule: "0 5/12 * * *"
|
|
||||||
jobTemplate:
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
serviceAccountName: redeploy
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: "registry-haumdaucher-de"
|
|
||||||
containers:
|
|
||||||
- name: redeploy-corona-api
|
|
||||||
image: registry.haumdaucher.de/redeploy:latest
|
|
||||||
env:
|
|
||||||
- name: DEPLOYMENT
|
|
||||||
value: "corona-api"
|
|
||||||
restartPolicy: Never
|
|
||||||
|
|
||||||
---
|
|
||||||
apiVersion: batch/v1beta1
|
|
||||||
kind: CronJob
|
|
||||||
metadata:
|
|
||||||
name: redeploy-corona
|
|
||||||
namespace: datalab
|
|
||||||
spec:
|
|
||||||
schedule: "15 5/12 * * *"
|
|
||||||
jobTemplate:
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
serviceAccountName: redeploy
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: "registry-haumdaucher-de"
|
|
||||||
containers:
|
|
||||||
- name: redeploy-corona
|
|
||||||
image: registry.haumdaucher.de/redeploy:latest
|
|
||||||
env:
|
|
||||||
- name: DEPLOYMENT
|
|
||||||
value: "corona"
|
|
||||||
restartPolicy: Never
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: duckdb-backend
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: duckdb-backend
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: duckdb-backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: duckdb-backend
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-haumdaucher-de
|
||||||
|
initContainers:
|
||||||
|
- name: git-init
|
||||||
|
image: alpine/git:latest
|
||||||
|
command: ["/bin/sh", "-c"]
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
export HOME=/tmp
|
||||||
|
cd /wiki
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo "Initializing wiki repository on dedicated PVC..."
|
||||||
|
REPO_URL="https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/knowledge-wiki.git"
|
||||||
|
git config --global http.sslVerify false
|
||||||
|
git config --global --add safe.directory /wiki
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init
|
||||||
|
git remote add origin "$REPO_URL" || git remote set-url origin "$REPO_URL"
|
||||||
|
git fetch origin main
|
||||||
|
git checkout -f -B main origin/main
|
||||||
|
echo "Clone completed successfully."
|
||||||
|
else
|
||||||
|
echo "Repository already initialized on PVC, updating..."
|
||||||
|
REPO_URL="https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/knowledge-wiki.git"
|
||||||
|
git config --global http.sslVerify false
|
||||||
|
git config --global --add safe.directory /wiki
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git remote set-url origin "$REPO_URL"
|
||||||
|
if [ -f .git/shallow ]; then
|
||||||
|
echo "Unshallowing existing repository..."
|
||||||
|
git fetch --unshallow origin main || git fetch origin main
|
||||||
|
else
|
||||||
|
git fetch origin main
|
||||||
|
fi
|
||||||
|
git rebase --abort 2>/dev/null || true
|
||||||
|
git merge --abort 2>/dev/null || true
|
||||||
|
git reset --hard origin/main
|
||||||
|
git clean -fd
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
- name: GIT_SYNC_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wiki-git-credentials
|
||||||
|
key: git-sync-username
|
||||||
|
- name: GIT_SYNC_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wiki-git-credentials
|
||||||
|
key: wiki-sync-token
|
||||||
|
volumeMounts:
|
||||||
|
- name: duckdb-wiki-data
|
||||||
|
mountPath: /wiki
|
||||||
|
containers:
|
||||||
|
- name: duckdb-backend
|
||||||
|
image: registry.haumdaucher.de/family/duckdb-backend:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
env:
|
||||||
|
- name: DATA_DIR
|
||||||
|
value: "/wiki/data"
|
||||||
|
- name: DUCKDB_DATA_DIR
|
||||||
|
value: "/wiki/data"
|
||||||
|
- name: WIKI_DIR
|
||||||
|
value: "/wiki"
|
||||||
|
- name: GIT_REPO_DIR
|
||||||
|
value: "/wiki"
|
||||||
|
- name: GIT_REPO_URL
|
||||||
|
value: "https://git.moritzgraf.de/moritz/knowledge-wiki.git"
|
||||||
|
- name: GIT_REMOTE_URL
|
||||||
|
value: "https://git.moritzgraf.de/moritz/knowledge-wiki.git"
|
||||||
|
- name: GIT_SYNC_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wiki-git-credentials
|
||||||
|
key: git-sync-username
|
||||||
|
- name: GIT_SYNC_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wiki-git-credentials
|
||||||
|
key: wiki-sync-token
|
||||||
|
- name: GIT_SYNC_INTERVAL_SECONDS
|
||||||
|
value: "60"
|
||||||
|
- name: PORT
|
||||||
|
value: "8000"
|
||||||
|
- name: HOST
|
||||||
|
value: "0.0.0.0"
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8000
|
||||||
|
volumeMounts:
|
||||||
|
- name: duckdb-wiki-data
|
||||||
|
mountPath: /wiki
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /duckdb-api/v1/healthz
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /duckdb-api/v1/healthz
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
volumes:
|
||||||
|
- name: duckdb-wiki-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: duckdb-wiki-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: duckdb-backend
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: duckdb-backend
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: duckdb-backend
|
||||||
Binary file not shown.
@@ -0,0 +1,55 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: family-portal
|
||||||
|
namespace: family
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: "traefik"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
kubernetes.io/tls-acme: "true"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- family.haumdaucher.de
|
||||||
|
secretName: family-haumdaucher-de-tls
|
||||||
|
rules:
|
||||||
|
- host: family.haumdaucher.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /duckdb-api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: duckdb-backend
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
|
- path: /tracking/mila
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: mila
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- path: /tracking/moritz
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: moritz
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- path: /outpost.goauthentik.io
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: duckdb-backend
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: mila
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: mila
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mila
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mila
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mila
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-haumdaucher-de
|
||||||
|
containers:
|
||||||
|
- name: mila
|
||||||
|
image: registry.haumdaucher.de/family/mila:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /tracking/mila/
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /tracking/mila/
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 15
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mila
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mila
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: mila
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: moritz
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: moritz
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: moritz
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: moritz
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-haumdaucher-de
|
||||||
|
containers:
|
||||||
|
- name: moritz
|
||||||
|
image: registry.haumdaucher.de/family/moritz:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /tracking/moritz/
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /tracking/moritz/
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 15
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: moritz
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: moritz
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: moritz
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: family
|
||||||
|
labels:
|
||||||
|
name: family
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: duckdb-wiki-data
|
||||||
|
namespace: family
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: duckdb-backend
|
||||||
|
app.kubernetes.io/part-of: family-portal
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: openebs-hostpath
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
Binary file not shown.
@@ -0,0 +1,71 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: haumdaucher-website
|
||||||
|
namespace: haumdaucher
|
||||||
|
labels:
|
||||||
|
app: haumdaucher
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: haumdaucher
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: haumdaucher
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: registry-haumdaucher-de
|
||||||
|
containers:
|
||||||
|
- name: haumdaucher
|
||||||
|
image: registry.haumdaucher.de/haumdaucher-website:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: haumdaucher-service
|
||||||
|
namespace: haumdaucher
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: haumdaucher
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
|
type: ClusterIP
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: haumdaucher-ingress
|
||||||
|
namespace: haumdaucher
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
kubernetes.io/ingress.class: nginx
|
||||||
|
kubernetes.io/tls-acme: "true"
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: haumdaucher.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: haumdaucher-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- haumdaucher.de
|
||||||
|
secretName: haumdaucher-de-tls
|
||||||
@@ -7,6 +7,40 @@ spec:
|
|||||||
addPrefix:
|
addPrefix:
|
||||||
prefix: /hosting
|
prefix: /hosting
|
||||||
---
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: hosting-redirect-mila
|
||||||
|
namespace: minio
|
||||||
|
spec:
|
||||||
|
redirectRegex:
|
||||||
|
regex: "^https?://([^/]+)/(?:family/)?(mila|sleep)\\.html.*"
|
||||||
|
replacement: "https://family.haumdaucher.de/tracking/mila/"
|
||||||
|
permanent: true
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: hosting-redirect-moritz
|
||||||
|
namespace: minio
|
||||||
|
spec:
|
||||||
|
redirectRegex:
|
||||||
|
regex: "^https?://([^/]+)/(?:family/)?(moritz|nutrition)\\.html.*"
|
||||||
|
replacement: "https://family.haumdaucher.de/tracking/moritz/"
|
||||||
|
permanent: true
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: hosting-redirect-family-root
|
||||||
|
namespace: minio
|
||||||
|
spec:
|
||||||
|
redirectRegex:
|
||||||
|
regex: "^https?://([^/]+)/family/?$"
|
||||||
|
replacement: "https://family.haumdaucher.de/"
|
||||||
|
permanent: true
|
||||||
|
---
|
||||||
|
# Public Ingress for hosting.haumdaucher.de (Recipes, Public Assets, Legacy Redirects)
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
@@ -16,7 +50,8 @@ metadata:
|
|||||||
kubernetes.io/ingress.class: "traefik"
|
kubernetes.io/ingress.class: "traefik"
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
kubernetes.io/tls-acme: "true"
|
kubernetes.io/tls-acme: "true"
|
||||||
traefik.ingress.kubernetes.io/router.middlewares: minio-hosting-add-prefix@kubernetescrd
|
traefik.ingress.kubernetes.io/router.priority: "10"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: minio-hosting-redirect-mila@kubernetescrd,minio-hosting-redirect-moritz@kubernetescrd,minio-hosting-redirect-family-root@kubernetescrd,minio-hosting-add-prefix@kubernetescrd
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -34,3 +69,40 @@ spec:
|
|||||||
- hosts:
|
- hosts:
|
||||||
- hosting.haumdaucher.de
|
- hosting.haumdaucher.de
|
||||||
secretName: hosting-haumdaucher-de-tls
|
secretName: hosting-haumdaucher-de-tls
|
||||||
|
---
|
||||||
|
# Protected Ingress for /family (Redirects and Authentik Outpost)
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: minio-hosting-family
|
||||||
|
namespace: minio
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: "traefik"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
kubernetes.io/tls-acme: "true"
|
||||||
|
traefik.ingress.kubernetes.io/router.priority: "100"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: minio-hosting-redirect-mila@kubernetescrd,minio-hosting-redirect-moritz@kubernetescrd,minio-hosting-redirect-family-root@kubernetescrd,auth-authentik-forwardauth@kubernetescrd,minio-hosting-add-prefix@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: hosting.haumdaucher.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /family
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: minio
|
||||||
|
port:
|
||||||
|
number: 9000
|
||||||
|
- path: /outpost.goauthentik.io
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: minio
|
||||||
|
port:
|
||||||
|
number: 9000
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- hosting.haumdaucher.de
|
||||||
|
secretName: hosting-haumdaucher-de-tls
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ The dashboard is exposed on your custom domain with TLS and basic authentication
|
|||||||
* **Username:** `admin`
|
* **Username:** `admin`
|
||||||
* **Password:** `moritz-z2m-2026`
|
* **Password:** `moritz-z2m-2026`
|
||||||
|
|
||||||
*Note: You can change the basic auth password by running the `k8s/htpasswd.py` script on your machine to generate a new bcrypt hash, then updating the `users` value in `k8s/monitoring/zigbee2mqtt.secret.yaml`.*
|
*Note: You can change the basic auth password by running the `scripts/htpasswd.py` script on your machine to generate a new bcrypt hash, then updating the `users` value in `k8s/monitoring/zigbee2mqtt.secret.yaml`.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+72
-5
@@ -40,7 +40,7 @@ The multi-container pod structure is managed natively by the operator controller
|
|||||||
- 🏡🎬 `agents/grabengasse_media.md` (Topic 164 "Grabengasse & Media" — Home Assistant, Hue, Sonos, Brother Drucker, Jellyfin & Spotify)
|
- 🏡🎬 `agents/grabengasse_media.md` (Topic 164 "Grabengasse & Media" — Home Assistant, Hue, Sonos, Brother Drucker, Jellyfin & Spotify)
|
||||||
* **Inter-Agent Peer Escalation (A2A)**: Every subagent topic is configured with peer escalation authority via `sessions_spawn(agentId="<target>", task="...")` to consult sibling agents when cross-domain expertise is needed.
|
* **Inter-Agent Peer Escalation (A2A)**: Every subagent topic is configured with peer escalation authority via `sessions_spawn(agentId="<target>", task="...")` to consult sibling agents when cross-domain expertise is needed.
|
||||||
* **Modifying & Adding Agents**: To update prompt logic, tone, or tool instructions for any agent, edit its markdown file directly in `knowledge-wiki/agents/<name>.md` and commit to git. No Kubernetes pod restarts required!
|
* **Modifying & Adding Agents**: To update prompt logic, tone, or tool instructions for any agent, edit its markdown file directly in `knowledge-wiki/agents/<name>.md` and commit to git. No Kubernetes pod restarts required!
|
||||||
* **Multi-Turn Domain Stickiness**: When a conversation spans multiple turns in a subagent domain (e.g. refining a recipe or planning a trip), the Conductor detects domain continuity and routes to the same agent with the state bridge `<!-- DOMAIN_STATE -->`.
|
* **Multi-Turn Domain Stickiness**: When a conversation spans multiple turns in a subagent domain (e.g. refining a recipe or planning a trip), the Conductor detects domain continuity and routes to the same agent with clean conversational context.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,11 +55,29 @@ To perform modifications or rotate api keys:
|
|||||||
kubectl apply -f k8s/openclaw/openclaw-instance.secret.yaml
|
kubectl apply -f k8s/openclaw/openclaw-instance.secret.yaml
|
||||||
kubectl apply -f k8s/openclaw/cronjob-wiki-backup.secret.yaml
|
kubectl apply -f k8s/openclaw/cronjob-wiki-backup.secret.yaml
|
||||||
```
|
```
|
||||||
4. **Register / Sync Automations**: Run `setup-itguy-cron.sh` to ensure the weekly health audit automation is registered in OpenClaw:
|
4. **Register / Sync Automations**: Run the automation setup scripts to register OpenClaw Cron tasks:
|
||||||
```bash
|
```bash
|
||||||
./k8s/openclaw/setup-itguy-cron.sh
|
./k8s/openclaw/setup-garmin-cron.sh # Nightly Garmin Connect sync (Daily 03:30)
|
||||||
|
./k8s/openclaw/setup-compaction-cron.sh # Nightly multi-agent session compaction (Daily 04:45)
|
||||||
|
./k8s/openclaw/setup-itguy-cron.sh # Weekly health audit (Sun 03:00)
|
||||||
|
# Or run declarative reconciler:
|
||||||
|
python3 k8s/openclaw/scripts/sync_crons.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🛡️ Everything-as-Code (EaC) Governance for New Crons, Skills & Binaries (ADR 0003)
|
||||||
|
|
||||||
|
To prevent PVC snowflake drift, **every** new automation, skill, CLI binary, or Python dependency added to OpenClaw MUST be persisted as code:
|
||||||
|
|
||||||
|
1. **New Cron Jobs (`Crons-as-Code`)**:
|
||||||
|
- **System / Recurring Automations**: Never leave a recurring cron job exclusively inside the pod's SQLite database (`openclaw cron add`). Every permanent automation MUST be codified in `k8s/openclaw/crons.yaml` (or the declarative cron sync scripts) and committed to `infrapuzzle`.
|
||||||
|
- **Dynamic / User-Created Reminders**: Conversational or temporary crons created by agents at runtime must be exported to `knowledge-wiki/operations/crons-backup.json` during nightly backup so they survive PVC loss.
|
||||||
|
2. **New Skills (`Skills-as-Code`)**:
|
||||||
|
- Never leave newly created or agent-authored skills only in `/home/node/.openclaw/skills/`, `workspace*/skills/`, or `skill-workshop/` on the PVC.
|
||||||
|
- All custom skills MUST be committed and pushed to the **`mop-skills`** repository (`git.moritzgraf.de/moritz/mop-skills`) so they are version-controlled and synced cleanly via the `git-sync` sidecar.
|
||||||
|
3. **New CLI Binaries, Packages & Patches (`Dependencies-as-Code`)**:
|
||||||
|
- Never manually drop binaries into `/home/node/.openclaw/bin/` or run ad-hoc `uv pip install` / `npm install -g` on the PVC without codifying them.
|
||||||
|
- Every new binary (e.g. `ffmpeg`, `kubectl`, `jq`, `openhue`, `sonos`), Python package in `mcp-venv`, or runtime patch MUST be added to the declarative bootstrap script (`k8s/openclaw/scripts/bootstrap-deps.sh` / `configmap-scripts.yaml`) so a fresh PVC rebuild has 100% tool parity.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. 🗂️ The Three Core Repositories Architecture & Weekly Self-Audit
|
### 4. 🗂️ The Three Core Repositories Architecture & Weekly Self-Audit
|
||||||
@@ -82,9 +100,58 @@ OpenClaw connects to three foundational Git repositories:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚨 Troubleshooting & Live Verification
|
## 🚨 Troubleshooting & Incident Playbook
|
||||||
|
|
||||||
If the agent is offline or failing:
|
> [!IMPORTANT]
|
||||||
|
> **Mandatory Bug & Incident Documentation**: Whenever a bug, outage, schema error, or operational regression is fixed in OpenClaw, **always document the incident in [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** with symptom, root cause, resolution, and how to avoid recurrence.
|
||||||
|
|
||||||
|
### 🛡️ Operational & Diagnostic Invariants (Learned Guidelines)
|
||||||
|
|
||||||
|
Whenever troubleshooting or maintaining OpenClaw, agents MUST adhere to these rules:
|
||||||
|
|
||||||
|
0. **Rapid Incident Triaging & Information Gathering (< 2s)**:
|
||||||
|
- When any issue, silence, or strange bot behavior is reported, **run the one-shot triage tool first**:
|
||||||
|
```bash
|
||||||
|
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 /home/node/.openclaw/scripts/triage.py --topic <topicId>
|
||||||
|
```
|
||||||
|
- Do NOT run piecemeal SQLite queries, guess table/column names, or scan the container filesystem.
|
||||||
|
- **Path Anchors (Never run `find /` or broad `grep -r` on rotational disk):**
|
||||||
|
- Knowledge Wiki: `/home/node/.openclaw/wiki/main`
|
||||||
|
- Agent Databases: `/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite`
|
||||||
|
- Gateway State DB: `/home/node/.openclaw/state/openclaw.sqlite`
|
||||||
|
- Standalone Triage Script: `/home/node/.openclaw/scripts/triage.py` (Repo: `k8s/openclaw/scripts/openclaw-triage.py`)
|
||||||
|
|
||||||
|
1. **Verify Before Declaring Dead (Turn In-Flight Diagnostics)**:
|
||||||
|
- When a user reports that a Telegram topic is "not answering" or "silent", **NEVER assume the container or gateway has crashed**.
|
||||||
|
- Always inspect live pod logs (`kubectl logs openclaw-0 -n openclaw -c openclaw --tail=100`) and the agent's SQLite database (`session_nodes` and `transcript_events`) first.
|
||||||
|
- Check whether a turn is currently executing: multi-turn tool loops or inline safeguard compaction (`compact:start`) can introduce multi-minute turn latency without any pod restart.
|
||||||
|
|
||||||
|
2. **Datastore Ground-Truth for Maintenance**:
|
||||||
|
- High-level CLI summaries (`openclaw sessions --all-agents --json`) report `totalTokens: null` (`totalTokensFresh: false`) for stale sessions or after restarts.
|
||||||
|
- Automated health checks and compaction scripts MUST query the SQLite database directly (`transcript_events` event count and byte size), triggering compaction when `bytes >= 300KB` or `events >= 40`, regardless of cached token values.
|
||||||
|
|
||||||
|
3. **Compaction User Visibility**:
|
||||||
|
- Always ensure `agents.defaults.compaction.notifyUser: true` remains set in OpenClaw configuration. Live inline compactions must send visible status feedback (`🧹 Compacting context...`) to avoid perceived bot stalls.
|
||||||
|
|
||||||
|
4. **In-Pod Container Restart Self-Healing**:
|
||||||
|
- Critical database unlocking, lockfile cleanup, and state recovery MUST run via runtime preflight (`NODE_OPTIONS="--import=..."`) or application startup hooks.
|
||||||
|
- Never place critical self-healing logic exclusively in `initContainers`, as Kubelet container restarts inside an existing Pod completely bypass `initContainers`.
|
||||||
|
|
||||||
|
5. **Rotational Storage (HDD) Page-Cache Pre-Warming**:
|
||||||
|
- Host `haumdaucher` uses rotational mechanical disks (`/dev/sda2`, `rotational: 1`).
|
||||||
|
- Whenever the pod or container restarts, SQLite databases MUST be sequentially streamed to `/dev/null` (`cat ... > /dev/null`) to pre-warm the Linux page cache, avoiding single-threaded event loop starvation on cold seeks.
|
||||||
|
|
||||||
|
6. **Zero-Downtime Wiki Prompt Updates vs. Heavy Pod Rollouts**:
|
||||||
|
- Subagent prompt instructions, tone, and tool guidelines live in `knowledge-wiki/agents/<name>.md`. Edits made there are read live by OpenClaw on git sync without any pod restart.
|
||||||
|
- Avoid modifying `openclaw-instance.secret.yaml` unless modifying container limits, environment variables, or global operator configuration. A CR edit triggers a 7-init-container rolling update (~3 minutes).
|
||||||
|
|
||||||
|
7. **Continuous Troubleshooting Evolution & The Simplicity Invariant (The 98% Rule)**:
|
||||||
|
- **Post-Fix Dual Reflection**: At the end of every troubleshooting cycle and fix, the AI MUST proactively evaluate:
|
||||||
|
1. *Troubleshooting Optimization*: How can the diagnostic process be made faster or codified into `triage.py` / `openclaw-ops`?
|
||||||
|
2. *System Maturity*: How can the root cause be hardened permanently to prevent recurrence?
|
||||||
|
- **Simplicity First**: Simplicity and straightforward architecture are the central design principles. A simple, pragmatic solution that cleanly reaches **98% of the goal** is strictly preferred over an over-engineered abstraction attempting 100%. Never introduce background workers, multi-stage state machines, or silent fallbacks when a transparent single-step tool or direct alert suffices.
|
||||||
|
|
||||||
|
For known issues, resolved bugs, and specific failure playbooks, consult [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
|
||||||
|
|
||||||
### 1. Verify Deployment Health
|
### 1. Verify Deployment Health
|
||||||
Ensure that the `OpenClawInstance` is successfully reconciled:
|
Ensure that the `OpenClawInstance` is successfully reconciled:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ The deployment is managed by the official **OpenClaw Operator** (`openclaw-opera
|
|||||||
* **Multi-Agent Specialist Delegation (ADR 0001)**:
|
* **Multi-Agent Specialist Delegation (ADR 0001)**:
|
||||||
* **Conductor + 9 Subagents**: Lean front-of-house coordinator orchestrates 9 specialized domain subagents defined as code in the knowledge wiki (`agents/*.md`).
|
* **Conductor + 9 Subagents**: Lean front-of-house coordinator orchestrates 9 specialized domain subagents defined as code in the knowledge wiki (`agents/*.md`).
|
||||||
* **Direct Fast Path (< 2s)**: Simple status, home automation toggles, and single calendar entries run directly in-place.
|
* **Direct Fast Path (< 2s)**: Simple status, home automation toggles, and single calendar entries run directly in-place.
|
||||||
* **Domain Stickiness**: Multi-turn dialogue (e.g. step-by-step cooking or vacation planning) stays pinned to the active subagent with state carried over via `<!-- DOMAIN_STATE -->`.
|
* **Domain Stickiness**: Multi-turn dialogue (e.g. step-by-step cooking or vacation planning) stays pinned to the active subagent with persistent session continuity.
|
||||||
* **Active MCP Servers**: `stadtrat` (ALLRIS Net), `supermarket` (Marktguru), `geizhals` (price histories), `ebay`, `garmin` (148 health tools), `bring` (4 lists), `spotify`, `calendar` (family + city), `jelly` (Jellyfin/Jellyseerr), `homeassistant`, `prometheus`, `gitea`, `weather`.
|
* **Active MCP Servers**: `stadtrat` (ALLRIS Net), `supermarket` (Marktguru), `geizhals` (price histories), `ebay`, `garmin` (148 health tools), `bring` (4 lists), `spotify`, `calendar` (family + city), `jelly` (Jellyfin/Jellyseerr), `homeassistant`, `prometheus`, `gitea`, `weather`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
data:
|
||||||
|
compact_sessions.py: "#!/usr/bin/env python3\n\"\"\"\nNightly multi-agent session
|
||||||
|
maintenance and compaction script for OpenClaw.\n1. Pre-warms OS page cache for
|
||||||
|
SQLite databases on spinning disks.\n2. Recovers any wedged or locked sessions
|
||||||
|
via recover_sessions.py.\n3. Prunes abandoned isolated cron sessions (>48h old)
|
||||||
|
to prevent token/state bloat.\n4. Tier A Hard-Reset: Resets koch, hebamme, and
|
||||||
|
shopping topics to 0 tokens (clean archive).\n5. Tier B Smart-Compaction: Compacts
|
||||||
|
active sessions exceeding token thresholds (5s pacing).\n6. Checkpoints SQLite
|
||||||
|
WAL journals across all agent databases to disk.\n7. Optimizes SQLite query planner
|
||||||
|
without blocking full VACUUM.\n\"\"\"\nimport glob\nimport json\nimport os\nimport
|
||||||
|
sqlite3\nimport subprocess\nimport sys\nimport time\n\nTHRESHOLD_TOKENS = 15000\nTHRESHOLD_TRANSCRIPT_BYTES
|
||||||
|
= 200000\nTHRESHOLD_EVENT_COUNT = 30\nCRON_MAX_AGE_HOURS = 48\nRPC_TIMEOUT_MS
|
||||||
|
= 180000\n\n# Tracking Channels: Deterministic sliding-window (mila_tracker, nutrition_tracker).\n#
|
||||||
|
Strict sliding window of ~5-8 events (2-3 turns max), 0 LLM tokens, instant truncation.\nTRACKING_TARGETS
|
||||||
|
= [\n (\"mila_tracker\", \"agent:mila_tracker:telegram:group:-1004326921510:topic:2796\"),\n
|
||||||
|
\ (\"nutrition_tracker\", \"agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797\"),\n]\nMAX_TRACKING_EVENTS
|
||||||
|
= 8\nMAX_TRACKING_LINES = 8\n\n# Tier A: High-frequency interaction topics (koch,
|
||||||
|
hebamme, shopping).\n# Deep-compaction keeps a compact summary + latest 2 turns
|
||||||
|
without completely wiping conversational memory.\nTIER_A_TARGETS = [\n (\"koch\",
|
||||||
|
\"agent:koch:telegram:group:-1004326921510:topic:21\"),\n (\"hebamme\", \"agent:hebamme:telegram:group:-1004326921510:topic:22\"),\n
|
||||||
|
\ (\"shopping\", \"agent:shopping:telegram:group:-1004326921510:topic:115\"),\n]\n\ndef
|
||||||
|
prewarm_page_cache():\n print(\"=== Pre-warming SQLite databases and Knowledge
|
||||||
|
Wiki into OS page cache ===\")\n t0 = time.time()\n try:\n subprocess.run(\n
|
||||||
|
\ \"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite
|
||||||
|
> /dev/null 2>&1 || true; \"\n \"find /home/node/.openclaw/wiki/main
|
||||||
|
/home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null
|
||||||
|
2>&1 || true\",\n shell=True,\n timeout=90.0\n )\n
|
||||||
|
\ print(f\"Page cache pre-warmed in {time.time() - t0:.2f}s\")\n except
|
||||||
|
Exception as e:\n print(f\"Page cache pre-warming warning: {e}\")\n\ndef
|
||||||
|
prune_stale_cron_sessions():\n print(f\"=== Pruning isolated cron sessions
|
||||||
|
older than {CRON_MAX_AGE_HOURS}h ===\")\n cutoff_ms = int(time.time() * 1000)
|
||||||
|
- (CRON_MAX_AGE_HOURS * 3600 * 1000)\n total_pruned = 0\n total_tokens_reclaimed
|
||||||
|
= 0\n\n for db in sorted(glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")):\n
|
||||||
|
\ agent = db.split(\"/\")[5]\n try:\n conn = sqlite3.connect(db,
|
||||||
|
timeout=30.0)\n conn.execute(\"PRAGMA foreign_keys = ON;\")\n cursor
|
||||||
|
= conn.cursor()\n cursor.execute(\"SELECT name FROM sqlite_master WHERE
|
||||||
|
type=\\\"table\\\"\")\n all_tables = set(r[0] for r in cursor.fetchall())\n\n
|
||||||
|
\ cursor.execute(\"SELECT session_key, current_session_id, updated_at,
|
||||||
|
entry_json FROM session_nodes WHERE session_key LIKE \\\"%:cron:%\\\"\")\n stale
|
||||||
|
= []\n for k, sid, up, ej in cursor.fetchall():\n if
|
||||||
|
up and up < cutoff_ms:\n tok = 0\n if ej:\n
|
||||||
|
\ try:\n tok = json.loads(ej).get(\"totalTokens\",
|
||||||
|
0) or 0\n except Exception:\n pass\n
|
||||||
|
\ stale.append((k, sid, tok))\n\n if stale:\n tokens_reclaimed
|
||||||
|
= sum(t[2] for t in stale)\n for k, sid, _ in stale:\n for
|
||||||
|
t in [\"trajectory_runtime_events\", \"transcript_events\", \"transcript_event_identities\",
|
||||||
|
\"session_transcript_active_events\", \"transcript_rewrite_watermarks\", \"session_transcript_index_state\",
|
||||||
|
\"session_transcript_fts\", \"session_conversations\"]:\n if
|
||||||
|
t in all_tables and sid:\n cursor.execute(f\"DELETE
|
||||||
|
FROM {t} WHERE session_id = ?\", (sid,))\n for t in [\"session_participants\",
|
||||||
|
\"heartbeat_outcomes\", \"session_progress_cards\", \"board_widgets\", \"board_tabs\"]:\n
|
||||||
|
\ if t in all_tables:\n cursor.execute(f\"DELETE
|
||||||
|
FROM {t} WHERE session_key = ?\", (k,))\n if \"memory_entry_origins\"
|
||||||
|
in all_tables:\n cursor.execute(\"DELETE FROM memory_entry_origins
|
||||||
|
WHERE session_key = ? OR session_id = ?\", (k, sid or \"\"))\n if
|
||||||
|
\"session_windows\" in all_tables:\n cursor.execute(\"DELETE
|
||||||
|
FROM session_windows WHERE session_key = ? OR session_id = ?\", (k, sid or \"\"))\n
|
||||||
|
\ cursor.execute(\"DELETE FROM session_nodes WHERE session_key
|
||||||
|
= ?\", (k,))\n conn.commit()\n total_pruned += len(stale)\n
|
||||||
|
\ total_tokens_reclaimed += tokens_reclaimed\n print(f\"
|
||||||
|
\ [{agent}] Pruned {len(stale)} dead cron sessions ({tokens_reclaimed} tokens
|
||||||
|
reclaimed)\")\n\n # Unconditionally resolve any dangling foreign key
|
||||||
|
references across all tables\n while True:\n cursor.execute(\"PRAGMA
|
||||||
|
foreign_key_check;\")\n violations = cursor.fetchall()\n if
|
||||||
|
not violations:\n break\n for table, rowid,
|
||||||
|
parent, fkid in violations:\n cursor.execute(f\"DELETE FROM
|
||||||
|
{table} WHERE rowid = ?\", (rowid,))\n conn.commit()\n conn.close()\n
|
||||||
|
\ except Exception as e:\n print(f\" [{agent}] Pruning error:
|
||||||
|
{e}\")\n\n # Clear resolved quarantine entries if all databases are healthy\n
|
||||||
|
\ try:\n q_path = \"/home/node/.openclaw/state/openclaw-quarantine.sqlite\"\n
|
||||||
|
\ if os.path.exists(q_path):\n q_conn = sqlite3.connect(q_path)\n
|
||||||
|
\ q_cur = q_conn.cursor()\n q_cur.execute(\"DELETE FROM quarantined_databases\")\n
|
||||||
|
\ q_conn.commit()\n q_conn.close()\n except Exception
|
||||||
|
as e:\n print(f\"Quarantine cleanup error: {e}\")\n\n print(f\"Cron
|
||||||
|
pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens
|
||||||
|
reclaimed.\")\n\ndef compact_tracking_sessions():\n print(\"=== Tracking Channels
|
||||||
|
Sliding-Window Maintenance (mila_tracker, nutrition_tracker) ===\")\n for agent,
|
||||||
|
key in TRACKING_TARGETS:\n db_path = f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n
|
||||||
|
\ if not os.path.exists(db_path):\n continue\n try:\n
|
||||||
|
\ con = sqlite3.connect(db_path, timeout=5.0)\n row = con.execute(\"SELECT
|
||||||
|
current_session_id FROM session_nodes WHERE session_key=?\", (key,)).fetchone()\n
|
||||||
|
\ if not row or not row[0]:\n con.close()\n print(f\"
|
||||||
|
\ [{agent}] No active session node found for {key}.\")\n continue\n
|
||||||
|
\ sid = row[0]\n cnt = con.execute(\"SELECT count(*) FROM
|
||||||
|
transcript_events WHERE session_id=?\", (sid,)).fetchone()[0]\n con.close()\n\n
|
||||||
|
\ if cnt > MAX_TRACKING_EVENTS:\n print(f\" [{agent}]
|
||||||
|
Session {key} has {cnt} events (> {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES}
|
||||||
|
lines...\")\n cmd = [\n \"openclaw\", \"sessions\",
|
||||||
|
\"compact\", key,\n \"--agent\", agent,\n \"--max-lines\",
|
||||||
|
str(MAX_TRACKING_LINES),\n \"--timeout\", \"30000\"\n ]\n
|
||||||
|
\ r = subprocess.run(cmd, capture_output=True, text=True)\n if
|
||||||
|
r.returncode == 0:\n print(f\" ✓ Truncated successfully:
|
||||||
|
{r.stdout.strip()}\")\n else:\n print(f\" ✗
|
||||||
|
Truncation failed: {r.stderr.strip()}\")\n else:\n print(f\"
|
||||||
|
\ [{agent}] Session {key} has {cnt} events (<= {MAX_TRACKING_EVENTS}). Clean.\")\n
|
||||||
|
\ except Exception as e:\n print(f\" ✗ [{agent}] Error during
|
||||||
|
tracking compaction: {e}\")\n\ndef compact_tier_a_sessions():\n print(\"===
|
||||||
|
Tier A: Deep-Compaction (koch, hebamme, shopping) ===\")\n for agent, key in
|
||||||
|
TIER_A_TARGETS:\n db_path = f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n
|
||||||
|
\ if not os.path.exists(db_path):\n continue\n try:\n
|
||||||
|
\ con = sqlite3.connect(db_path, timeout=10.0)\n row = con.execute(\"SELECT
|
||||||
|
current_session_id FROM session_nodes WHERE session_key=?\", (key,)).fetchone()\n
|
||||||
|
\ con.close()\n if not row or not row[0]:\n print(f\"
|
||||||
|
\ [Tier A] {agent} ({key}) already at 0 (no active session).\")\n continue\n
|
||||||
|
\ ev_cnt, ev_bytes = get_session_metrics(agent, key, row[0])\n if
|
||||||
|
ev_cnt <= 2 and ev_bytes < 20000:\n print(f\" [Tier A] {agent}
|
||||||
|
({key}) has only {ev_cnt} events ({ev_bytes} bytes), skipping.\")\n continue\n
|
||||||
|
\ print(f\" [Tier A] Deep-compacting {agent} ({key}) [{ev_cnt} events,
|
||||||
|
{ev_bytes//1024}KB]...\")\n r = subprocess.run(\n [\"openclaw\",
|
||||||
|
\"sessions\", \"compact\", key, \"--agent\", agent, \"--timeout\", str(RPC_TIMEOUT_MS)],\n
|
||||||
|
\ capture_output=True, text=True\n )\n if
|
||||||
|
r.returncode == 0:\n print(f\" ✓ Deep-compacted: {r.stdout.strip()}\")\n
|
||||||
|
\ elif \"Already compacted\" in r.stderr or \"Already compacted\" in
|
||||||
|
r.stdout:\n print(f\" ✓ Already compacted.\")\n else:\n
|
||||||
|
\ print(f\" ✗ Standard compact error: {r.stderr.strip()}; retrying
|
||||||
|
with --max-lines 40...\")\n fb = subprocess.run(\n [\"openclaw\",
|
||||||
|
\"sessions\", \"compact\", key, \"--agent\", agent, \"--max-lines\", \"40\", \"--timeout\",
|
||||||
|
str(RPC_TIMEOUT_MS)],\n capture_output=True, text=True\n )\n
|
||||||
|
\ if fb.returncode == 0:\n print(f\" ✓ Truncation
|
||||||
|
fallback success: {fb.stdout.strip()}\")\n else:\n print(f\"
|
||||||
|
\ ✗ Truncation fallback failed: {fb.stderr.strip()}\")\n except Exception
|
||||||
|
as e:\n print(f\" ✗ Exception deep-compacting {agent}: {e}\")\n
|
||||||
|
\ time.sleep(2)\n\ndef get_session_metrics(agent, key, sid=None):\n db_path
|
||||||
|
= f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n if
|
||||||
|
not os.path.exists(db_path):\n return 0, 0\n try:\n con = sqlite3.connect(db_path,
|
||||||
|
timeout=5.0)\n cur = con.cursor()\n if not sid:\n row
|
||||||
|
= cur.execute(\"SELECT current_session_id FROM session_nodes WHERE session_key=?\",
|
||||||
|
(key,)).fetchone()\n sid = row[0] if row else None\n if not
|
||||||
|
sid:\n con.close()\n return 0, 0\n ev_cnt, ev_bytes
|
||||||
|
= cur.execute(\n \"SELECT count(*), coalesce(sum(length(event_json)),
|
||||||
|
0) FROM transcript_events WHERE session_id=?\", \n (sid,)\n ).fetchone()\n
|
||||||
|
\ con.close()\n return ev_cnt, ev_bytes\n except Exception:\n
|
||||||
|
\ return 0, 0\n\ndef compact_active_sessions():\n excluded_keys = set(t[1]
|
||||||
|
for t in TIER_A_TARGETS) | set(t[1] for t in TRACKING_TARGETS)\n print(f\"===
|
||||||
|
Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens /
|
||||||
|
{THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===\")\n res = subprocess.run([\"openclaw\",
|
||||||
|
\"sessions\", \"--all-agents\", \"--json\"], capture_output=True, text=True)\n
|
||||||
|
\ if res.returncode != 0:\n print(f\"Failed to list sessions: {res.stderr}\")\n
|
||||||
|
\ return\n\n sessions = json.loads(res.stdout).get(\"sessions\", [])\n
|
||||||
|
\ compacted = 0\n skipped = 0\n\n for s in sessions:\n key = s.get(\"key\",
|
||||||
|
\"\")\n agent = s.get(\"agentId\") or \"main\"\n tokens = s.get(\"totalTokens\")
|
||||||
|
or 0\n sid = s.get(\"sessionId\")\n\n # Skip Tier A and Tracking
|
||||||
|
agents as they have dedicated routines\n if key in excluded_keys:\n continue\n\n
|
||||||
|
\ # Target main sessions and all telegram sessions (topics and direct)\n
|
||||||
|
\ is_target = (\n key == \"agent:main:main\" or \n \":telegram:\"
|
||||||
|
in key or\n (key.startswith(\"agent:\") and key.endswith(\":main\"))\n
|
||||||
|
\ )\n if is_target and not key.endswith(\":heartbeat\"):\n ev_cnt,
|
||||||
|
ev_bytes = get_session_metrics(agent, key, sid)\n needs_compaction
|
||||||
|
= (\n tokens >= THRESHOLD_TOKENS or\n ev_bytes >=
|
||||||
|
THRESHOLD_TRANSCRIPT_BYTES or\n (tokens == 0 and ev_cnt >= THRESHOLD_EVENT_COUNT)\n
|
||||||
|
\ )\n if needs_compaction:\n stat_str = f\"{tokens}
|
||||||
|
tokens\" if tokens else f\"{ev_bytes//1024}KB / {ev_cnt} events\"\n print(f\"Compacting
|
||||||
|
{agent} ({stat_str}) -> {key}...\")\n c_res = subprocess.run(\n
|
||||||
|
\ [\"openclaw\", \"sessions\", \"compact\", key, \"--agent\",
|
||||||
|
agent, \"--timeout\", str(RPC_TIMEOUT_MS)],\n capture_output=True,
|
||||||
|
text=True\n )\n if c_res.returncode == 0:\n print(f\"
|
||||||
|
\ ✓ {c_res.stdout.strip()}\")\n compacted += 1\n elif
|
||||||
|
\"Already compacted\" in c_res.stderr or \"Already compacted\" in c_res.stdout:\n
|
||||||
|
\ print(f\" ✓ Already compacted.\")\n skipped
|
||||||
|
+= 1\n else:\n print(f\" ✗ {c_res.stderr.strip()};
|
||||||
|
retrying with --max-lines 80 fallback...\")\n fb_res = subprocess.run(\n
|
||||||
|
\ [\"openclaw\", \"sessions\", \"compact\", key, \"--agent\",
|
||||||
|
agent, \"--max-lines\", \"80\", \"--timeout\", str(RPC_TIMEOUT_MS)],\n capture_output=True,
|
||||||
|
text=True\n )\n if fb_res.returncode ==
|
||||||
|
0:\n print(f\" ✓ (fallback) {fb_res.stdout.strip()}\")\n
|
||||||
|
\ compacted += 1\n else:\n print(f\"
|
||||||
|
\ ✗ Fallback failed: {fb_res.stderr.strip()}\")\n time.sleep(5)
|
||||||
|
\ # 5s pause to avoid rotational HDD I/O saturation\n else:\n skipped
|
||||||
|
+= 1\n\n print(f\"Compaction complete: {compacted} compacted, {skipped} skipped.\")\n\ndef
|
||||||
|
checkpoint_wal_journals():\n print(\"=== Checkpointing SQLite WAL journals
|
||||||
|
across all agent databases ===\")\n dbs = [\"/home/node/.openclaw/state/openclaw.sqlite\"]
|
||||||
|
+ glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")\n wal_freed
|
||||||
|
= 0\n for db in dbs:\n wal = db + \"-wal\"\n if os.path.exists(wal):\n
|
||||||
|
\ sz = os.path.getsize(wal)\n try:\n con =
|
||||||
|
sqlite3.connect(db, timeout=30.0)\n con.execute(\"PRAGMA wal_checkpoint(TRUNCATE);\")\n
|
||||||
|
\ con.close()\n wal_freed += sz\n except
|
||||||
|
Exception as e:\n print(f\" ✗ Error checkpointing {db}: {e}\")\n
|
||||||
|
\ print(f\"WAL Checkpoint complete: {wal_freed / (1024*1024):.2f} MB merged
|
||||||
|
to disk.\")\n\ndef vacuum_and_optimize():\n print(\"=== Running SQLite OPTIMIZE
|
||||||
|
across all databases ===\")\n dbs = [\"/home/node/.openclaw/state/openclaw.sqlite\"]
|
||||||
|
+ glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")\n for
|
||||||
|
db in dbs:\n try:\n con = sqlite3.connect(db, timeout=30.0)\n
|
||||||
|
\ con.execute(\"PRAGMA optimize;\")\n con.close()\n except
|
||||||
|
Exception as e:\n print(f\" ✗ Error optimizing {db}: {e}\")\n print(\"SQLite
|
||||||
|
optimization complete.\")\n\ndef main():\n prewarm_page_cache()\n try:\n
|
||||||
|
\ subprocess.run([\"python3\", \"/home/node/.openclaw/scripts/recover_sessions.py\"],
|
||||||
|
check=False)\n except Exception:\n pass\n prune_stale_cron_sessions()\n
|
||||||
|
\ compact_tracking_sessions()\n compact_tier_a_sessions()\n compact_active_sessions()\n
|
||||||
|
\ checkpoint_wal_journals()\n vacuum_and_optimize()\n prewarm_page_cache()\n\nif
|
||||||
|
__name__ == \"__main__\":\n main()\n"
|
||||||
|
dedup_subagent_vectors.py: |
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import glob, json, os, sqlite3, sys
|
||||||
|
|
||||||
|
cfg_path = "/home/node/.openclaw/openclaw.json"
|
||||||
|
if os.path.exists(cfg_path):
|
||||||
|
try:
|
||||||
|
with open(cfg_path, "r") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
mem_search = cfg.get("memory", {}).get("search", {})
|
||||||
|
if mem_search.get("extraPaths"):
|
||||||
|
mem_search["extraPaths"] = []
|
||||||
|
with open(cfg_path, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
print("[dedup_subagents] Cleared top-level memory.search.extraPaths from openclaw.json (scoped to main only)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[dedup_subagents] Warning checking openclaw.json: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
|
||||||
|
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
|
||||||
|
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
|
||||||
|
if agent == "main":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db, timeout=10.0)
|
||||||
|
if os.path.exists(vec_path):
|
||||||
|
conn.enable_load_extension(True)
|
||||||
|
conn.load_extension(vec_path)
|
||||||
|
cur = conn.cursor()
|
||||||
|
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
|
||||||
|
if chk > 0:
|
||||||
|
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
|
||||||
|
for t in [
|
||||||
|
"memory_index_sources",
|
||||||
|
"memory_index_chunks",
|
||||||
|
"memory_index_chunk_recall_metadata",
|
||||||
|
"memory_index_chunk_provenance",
|
||||||
|
"memory_entry_origins",
|
||||||
|
"memory_embedding_cache",
|
||||||
|
"memory_index_chunks_vec"
|
||||||
|
]:
|
||||||
|
try: cur.execute(f"DELETE FROM {t}")
|
||||||
|
except Exception: pass
|
||||||
|
conn.commit()
|
||||||
|
cur.execute("VACUUM")
|
||||||
|
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
|
||||||
|
gateway-preflight.mjs: |
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execSync, spawn } from "node:child_process";
|
||||||
|
|
||||||
|
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
|
||||||
|
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
|
||||||
|
|
||||||
|
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
|
||||||
|
|
||||||
|
if (isGateway) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
console.log("[gateway-preflight] Initializing container self-healing preflight...");
|
||||||
|
|
||||||
|
// 1. Clean up stale gateway lock files from ungraceful kills
|
||||||
|
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith("gateway") && file.endsWith(".lock")) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clear false database quarantines
|
||||||
|
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
|
||||||
|
if (fs.existsSync(qPath)) {
|
||||||
|
try {
|
||||||
|
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
|
||||||
|
console.log("[gateway-preflight] Cleared database quarantine.");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
|
||||||
|
try {
|
||||||
|
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
|
||||||
|
try {
|
||||||
|
const pw0 = Date.now();
|
||||||
|
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
|
||||||
|
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 5. Recover any wedged running sessions left over from ungraceful container restart
|
||||||
|
try {
|
||||||
|
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[gateway-preflight] Session recovery warning:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Spawn background turn replay daemon if interrupted turns were detected
|
||||||
|
try {
|
||||||
|
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
|
||||||
|
console.log("[gateway-preflight] Spawning background turn replay daemon...");
|
||||||
|
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore"
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openclaw-triage.py: "#!/usr/bin/env python3\n\"\"\"\nopenclaw-triage.py: Rapid zero-dependency
|
||||||
|
incident triage for OpenClaw multi-agent sessions.\nDirectly inspects ground-truth
|
||||||
|
SQLite session state and transcript events in < 1 second.\n\"\"\"\nimport argparse\nimport
|
||||||
|
glob\nimport json\nimport os\nimport sqlite3\nimport sys\nimport time\n\nTOPIC_TO_AGENT
|
||||||
|
= {\n \"20\": \"fitness\",\n \"21\": \"koch\",\n \"22\": \"hebamme\",\n
|
||||||
|
\ \"51\": \"travel\",\n \"115\": \"shopping\",\n \"116\": \"regensburg\",\n
|
||||||
|
\ \"117\": \"recherche\",\n \"118\": \"doktor\",\n \"119\": \"itguy\",\n
|
||||||
|
\ \"164\": \"grabengasse_media\",\n \"2796\": \"mila_tracker\",\n \"2797\":
|
||||||
|
\"nutrition_tracker\",\n}\n\ndef parse_args():\n parser = argparse.ArgumentParser(description=\"Rapid
|
||||||
|
OpenClaw multi-agent session triage.\")\n parser.add_argument(\"--topic\",
|
||||||
|
\"-t\", help=\"Telegram forum topic ID (e.g. 21, 22, 117)\")\n parser.add_argument(\"--agent\",
|
||||||
|
\"-a\", help=\"Agent identifier (e.g. koch, hebamme, recherche)\")\n parser.add_argument(\"--limit\",
|
||||||
|
\"-n\", type=int, default=10, help=\"Number of recent events to display (default:
|
||||||
|
10)\")\n parser.add_argument(\"--db-root\", default=\"/home/node/.openclaw/agents\",
|
||||||
|
help=\"Root path of agent SQLite databases\")\n return parser.parse_args()\n\ndef
|
||||||
|
triage_agent_session(agent, topic_id=None, limit=10, db_root=\"/home/node/.openclaw/agents\"):\n
|
||||||
|
\ db_path = os.path.join(db_root, agent, \"agent\", \"openclaw-agent.sqlite\")\n
|
||||||
|
\ if not os.path.exists(db_path):\n print(f\"[-] Database not found for
|
||||||
|
agent '{agent}': {db_path}\")\n return False\n\n try:\n con =
|
||||||
|
sqlite3.connect(db_path, timeout=5.0)\n except Exception as e:\n print(f\"[-]
|
||||||
|
Failed to connect to SQLite DB for agent '{agent}': {e}\")\n return False\n\n
|
||||||
|
\ cur = con.cursor()\n # Find matching session\n if topic_id:\n target_pattern
|
||||||
|
= f\"%topic:{topic_id}%\"\n cur.execute(\n \"SELECT session_key,
|
||||||
|
current_session_id, status, updated_at, entry_json FROM session_nodes WHERE session_key
|
||||||
|
LIKE ? AND session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1\",\n
|
||||||
|
\ (target_pattern,)\n )\n else:\n cur.execute(\n \"SELECT
|
||||||
|
session_key, current_session_id, status, updated_at, entry_json FROM session_nodes
|
||||||
|
WHERE session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1\"\n
|
||||||
|
\ )\n\n row = cur.fetchone()\n if not row:\n print(f\"[-] No
|
||||||
|
session found in {agent} DB (filter: topic={topic_id})\")\n con.close()\n
|
||||||
|
\ return False\n\n skey, sid, status, updated_at_ms, entry_json = row\n
|
||||||
|
\ now_ms = time.time() * 1000\n age_min = (now_ms - updated_at_ms) / 60000.0
|
||||||
|
if updated_at_ms else 0.0\n\n entry = {}\n if entry_json:\n try:\n
|
||||||
|
\ entry = json.loads(entry_json)\n except Exception:\n pass\n\n
|
||||||
|
\ tokens = entry.get(\"totalTokens\", \"?\")\n model = entry.get(\"model\",
|
||||||
|
\"default\")\n last_run_id = entry.get(\"activeWriterRunId\") or entry.get(\"activeRunId\",
|
||||||
|
None)\n\n # Detect anomalies\n badges = []\n if isinstance(tokens, int):\n
|
||||||
|
\ if tokens > 60000:\n badges.append(\"\U0001F6A8 [CRITICAL_CONTEXT_BLOAT:
|
||||||
|
>60k tokens]\")\n elif tokens > 20000:\n badges.append(\"⚠️
|
||||||
|
[HIGH_CONTEXT_TOKENS: >20k tokens]\")\n if status == \"running\" and age_min
|
||||||
|
> 5.0:\n badges.append(\"\U0001F512 [POSSIBLY_WEDGED_RUNNING: updated >5m
|
||||||
|
ago]\")\n elif status == \"processing\":\n badges.append(\"⚡ [ACTIVE_PROCESSING]\")\n\n
|
||||||
|
\ # Check ingress queue status\n state_db = \"/home/node/.openclaw/state/openclaw.sqlite\"\n
|
||||||
|
\ if topic_id and os.path.exists(state_db):\n try:\n s_con
|
||||||
|
= sqlite3.connect(state_db, timeout=2.0)\n lane_pat = f\"%topic:{topic_id}%\"\n
|
||||||
|
\ pending_cnt = s_con.execute(\n \"SELECT count(*) FROM
|
||||||
|
channel_ingress_events WHERE lane_key LIKE ? AND status IN ('pending', 'claimed')\",\n
|
||||||
|
\ (lane_pat,)\n ).fetchone()[0]\n s_con.close()\n
|
||||||
|
\ if pending_cnt > 0:\n badges.append(f\"\U0001F4EC [INGRESS_STALLED:
|
||||||
|
{pending_cnt} message(s) pending]\")\n except Exception:\n pass\n\n
|
||||||
|
\ print(\"=\" * 72)\n print(f\"\U0001F3AF AGENT: {agent.upper():<12} | TOPIC:
|
||||||
|
{topic_id or 'N/A':<6} | STATUS: {status.upper()}\")\n print(f\" SESSION
|
||||||
|
KEY: {skey}\")\n print(f\" SESSION ID: {sid}\")\n print(f\" MODEL:
|
||||||
|
\ {model}\")\n print(f\" TOKENS: {tokens} | LAST UPDATED: {age_min:.1f}m
|
||||||
|
ago\")\n if badges:\n print(f\" ANOMALIES: {' '.join(badges)}\")\n
|
||||||
|
\ print(\"=\" * 72)\n\n # Query transcript events\n cur.execute(\n \"SELECT
|
||||||
|
seq, created_at, event_json FROM transcript_events WHERE session_id = ? ORDER
|
||||||
|
BY seq DESC LIMIT ?\",\n (sid, limit)\n )\n events = cur.fetchall()\n
|
||||||
|
\ con.close()\n\n if not events:\n print(\" (No transcript events
|
||||||
|
found)\")\n return True\n\n events.reverse()\n print(\"\\n\U0001F4DC
|
||||||
|
RECENT TRANSCRIPT EVENTS (Chronological):\")\n\n last_role = None\n for
|
||||||
|
seq, ca_ms, ej_str in events:\n try:\n ev = json.loads(ej_str)\n
|
||||||
|
\ except Exception:\n print(f\"[{seq}] <unparseable event json>\")\n
|
||||||
|
\ continue\n\n etype = ev.get(\"type\")\n if etype !=
|
||||||
|
\"message\":\n continue\n\n msg = ev.get(\"message\", {})\n
|
||||||
|
\ role = msg.get(\"role\")\n last_role = role\n content =
|
||||||
|
msg.get(\"content\", [])\n\n if role == \"user\":\n txt = content
|
||||||
|
if isinstance(content, str) else json.dumps(content)\n # Check for
|
||||||
|
audio transcript\n if isinstance(content, str) and \"[Audio]\" in content:\n
|
||||||
|
\ print(f\"\\n\U0001F449 [SEQ {seq}] \U0001F3A4 USER AUDIO PROMPT:\")\n
|
||||||
|
\ for line in content.splitlines()[:6]:\n print(f\"
|
||||||
|
\ {line}\")\n if len(content.splitlines()) > 6:\n print(\"
|
||||||
|
\ ...\")\n else:\n txt_preview = txt.replace(\"\\n\",
|
||||||
|
\" \")[:240]\n print(f\"\\n\U0001F449 [SEQ {seq}] \U0001F464 USER:
|
||||||
|
{txt_preview}\")\n\n elif role == \"assistant\":\n print(f\"\\n\U0001F916
|
||||||
|
[SEQ {seq}] ASSISTANT TURN:\")\n if isinstance(content, list):\n for
|
||||||
|
item in content:\n if not isinstance(item, dict):\n continue\n
|
||||||
|
\ itype = item.get(\"type\")\n if itype ==
|
||||||
|
\"thinking\":\n th = item.get(\"thinking\", \"\").replace(\"\\n\",
|
||||||
|
\" \")\n print(f\" \U0001F9E0 THINKING: {th[:280]}...\")\n
|
||||||
|
\ elif itype == \"toolCall\":\n tname
|
||||||
|
= item.get(\"name\", \"unknown\")\n targs = json.dumps(item.get(\"arguments\",
|
||||||
|
{}))\n print(f\" ⚙️ TOOL CALL: {tname}({targs[:160]})\")\n
|
||||||
|
\ elif itype == \"text\":\n tx = item.get(\"text\",
|
||||||
|
\"\")\n lines = tx.splitlines()\n prev
|
||||||
|
= lines[0][:150] if lines else \"\"\n print(f\" \U0001F4AC
|
||||||
|
TEXT OUTPUT: {prev}...\")\n elif isinstance(content, str):\n print(f\"
|
||||||
|
\ \U0001F4AC TEXT OUTPUT: {content[:200]}...\")\n\n elif role == \"toolResult\":\n
|
||||||
|
\ is_err = ev.get(\"isError\", False)\n tname = ev.get(\"details\",
|
||||||
|
{}).get(\"tool\") or msg.get(\"toolName\", \"tool\")\n res_txt = json.dumps(content)
|
||||||
|
if not isinstance(content, str) else content\n if is_err or \"error\"
|
||||||
|
in res_txt.lower() or \"must have required property\" in res_txt:\n print(f\"
|
||||||
|
\ ❌ [SEQ {seq}] TOOL ERROR ({tname}): {res_txt[:260]}\")\n else:\n
|
||||||
|
\ print(f\" ✅ [SEQ {seq}] TOOL RESULT ({tname}): {res_txt[:180]}\")\n\n
|
||||||
|
\ if last_role == \"user\":\n print(\"\\n⚠️ WARNING: The last event in
|
||||||
|
the transcript is an UNANSWERED USER MESSAGE!\")\n print(\" The agent
|
||||||
|
turn either crashed, timed out, or was killed before producing a response.\")\n\n
|
||||||
|
\ print(\"\\n\" + \"=\" * 72)\n return True\n\ndef main():\n args = parse_args()\n
|
||||||
|
\ if not args.topic and not args.agent:\n print(\"Usage: openclaw-triage.py
|
||||||
|
(--topic <ID> | --agent <NAME>) [--limit <N>]\")\n sys.exit(1)\n\n agent
|
||||||
|
= args.agent\n topic = args.topic\n\n if topic and not agent:\n agent
|
||||||
|
= TOPIC_TO_AGENT.get(str(topic))\n if not agent:\n # Search
|
||||||
|
all DBs\n for a, t in TOPIC_TO_AGENT.items():\n if t
|
||||||
|
== topic:\n agent = a\n break\n\n if
|
||||||
|
not agent:\n # Scan all DBs for the topic\n for db in sorted(glob.glob(os.path.join(args.db_root,
|
||||||
|
\"*/agent/openclaw-agent.sqlite\"))):\n cand_agent = db.split(os.sep)[-3]\n
|
||||||
|
\ try:\n con = sqlite3.connect(db, timeout=2.0)\n cur
|
||||||
|
= con.cursor()\n cur.execute(\n \"SELECT session_key
|
||||||
|
FROM session_nodes WHERE session_key LIKE ? LIMIT 1\",\n (f\"%topic:{topic}%\",)\n
|
||||||
|
\ )\n if cur.fetchone():\n agent
|
||||||
|
= cand_agent\n con.close()\n break\n con.close()\n
|
||||||
|
\ except Exception:\n pass\n\n if not agent:\n print(f\"[-]
|
||||||
|
Could not map topic '{topic}' to any known OpenClaw agent.\")\n sys.exit(1)\n\n
|
||||||
|
\ success = triage_agent_session(agent, topic_id=topic, limit=args.limit, db_root=args.db_root)\n
|
||||||
|
\ sys.exit(0 if success else 1)\n\nif __name__ == \"__main__\":\n main()\n"
|
||||||
|
recover_sessions.py: |+
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import glob, sqlite3, json, os, sys, time
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
recovered = 0
|
||||||
|
interrupted_turns = []
|
||||||
|
|
||||||
|
def extract_prompt(msg):
|
||||||
|
if not msg: return ""
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, str): return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for c in content:
|
||||||
|
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
|
||||||
|
elif isinstance(c, str): parts.append(c)
|
||||||
|
return "\n".join(filter(None, parts))
|
||||||
|
return str(content or "")
|
||||||
|
|
||||||
|
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db, timeout=10.0)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
while True:
|
||||||
|
cursor.execute("PRAGMA foreign_key_check;")
|
||||||
|
violations = cursor.fetchall()
|
||||||
|
if not violations: break
|
||||||
|
for table, rowid, parent, fkid in violations:
|
||||||
|
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
|
||||||
|
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
|
||||||
|
for sk, ej, csid, st in rows:
|
||||||
|
try:
|
||||||
|
p = json.loads(ej) if ej else {}
|
||||||
|
sid = csid or p.get("sessionId")
|
||||||
|
if sid:
|
||||||
|
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
|
||||||
|
if ev:
|
||||||
|
ev_seq, ev_raw = ev
|
||||||
|
try:
|
||||||
|
ev_data = json.loads(ev_raw)
|
||||||
|
msg = ev_data.get("message") or ev_data
|
||||||
|
if st == "running" and msg.get("role") == "user":
|
||||||
|
prompt_text = extract_prompt(msg)
|
||||||
|
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
|
||||||
|
interrupted_turns.append({
|
||||||
|
"agent": agent_id,
|
||||||
|
"session_key": sk,
|
||||||
|
"session_id": sid,
|
||||||
|
"context": ctx,
|
||||||
|
"prompt": prompt_text
|
||||||
|
})
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
|
||||||
|
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
|
||||||
|
p["status"] = "done"
|
||||||
|
p["activeWriterRunId"] = None
|
||||||
|
p["activeClaimRunId"] = None
|
||||||
|
p["restartRecoveryDeliveryRunId"] = None
|
||||||
|
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
|
||||||
|
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
|
||||||
|
recovered += 1
|
||||||
|
except Exception: pass
|
||||||
|
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
|
||||||
|
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
|
||||||
|
if os.path.exists(state_db):
|
||||||
|
try:
|
||||||
|
sconn = sqlite3.connect(state_db, timeout=10.0)
|
||||||
|
scur = sconn.cursor()
|
||||||
|
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
|
||||||
|
sconn.commit()
|
||||||
|
sconn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if interrupted_turns:
|
||||||
|
try:
|
||||||
|
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
|
||||||
|
json.dump(interrupted_turns, f, indent=2)
|
||||||
|
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
|
||||||
|
|
||||||
|
replay_interrupted_turns.py: |
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os, subprocess, sys, time, urllib.request
|
||||||
|
|
||||||
|
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
|
||||||
|
|
||||||
|
if not os.path.exists(INTERRUPTED_FILE):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(INTERRUPTED_FILE, "r") as f:
|
||||||
|
turns = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not turns:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
|
||||||
|
|
||||||
|
gateway_ready = False
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
gateway_ready = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if not gateway_ready:
|
||||||
|
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Allow 5s for Telegram ingress to bind
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
for turn in turns:
|
||||||
|
sk = turn.get("session_key")
|
||||||
|
sid = turn.get("session_id")
|
||||||
|
agent_id = turn.get("agent")
|
||||||
|
ctx = turn.get("context") or {}
|
||||||
|
prompt = turn.get("prompt") or ""
|
||||||
|
|
||||||
|
channel = ctx.get("channel") or "telegram"
|
||||||
|
to_target = ctx.get("to") or ""
|
||||||
|
thread_id = ctx.get("threadId")
|
||||||
|
|
||||||
|
target = to_target
|
||||||
|
if target.startswith("telegram:"):
|
||||||
|
parts = target.split(":")
|
||||||
|
target = parts[1] if len(parts) > 1 else target
|
||||||
|
|
||||||
|
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
|
||||||
|
|
||||||
|
# 1. Send status notification to Telegram if target exists
|
||||||
|
if target:
|
||||||
|
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
|
||||||
|
notify_cmd = [
|
||||||
|
"openclaw", "message", "send",
|
||||||
|
"--channel", channel,
|
||||||
|
"--target", str(target),
|
||||||
|
"-m", notice
|
||||||
|
]
|
||||||
|
if thread_id is not None:
|
||||||
|
notify_cmd.extend(["--thread-id", str(thread_id)])
|
||||||
|
try:
|
||||||
|
subprocess.run(notify_cmd, check=False, timeout=30)
|
||||||
|
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 2. Replay agent turn
|
||||||
|
if sk and prompt:
|
||||||
|
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
|
||||||
|
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
|
||||||
|
agent_cmd = [
|
||||||
|
"openclaw", "agent",
|
||||||
|
"--session-key", sk,
|
||||||
|
"-m", prompt,
|
||||||
|
"--deliver"
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
if res.returncode == 0:
|
||||||
|
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
|
||||||
|
else:
|
||||||
|
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(INTERRUPTED_FILE)
|
||||||
|
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: openclaw-scripts
|
||||||
|
namespace: openclaw
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,137 @@
|
|||||||
|
# Declarative Crons-as-Code for OpenClaw (Haumdaucher)
|
||||||
|
# Reconciled idempotently via k8s/openclaw/scripts/sync_crons.py
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
jobs:
|
||||||
|
# 1. Maintenance & State Truncation
|
||||||
|
- name: "tracking-session-sliding-window"
|
||||||
|
declarationKey: "tracking-session-sliding-window"
|
||||||
|
description: "Lightweight sliding-window truncation (max 8 events) for mila_tracker and nutrition_tracker"
|
||||||
|
schedule:
|
||||||
|
kind: "every"
|
||||||
|
every: "15m"
|
||||||
|
session: "isolated"
|
||||||
|
delivery: "none"
|
||||||
|
command: "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
|
||||||
|
|
||||||
|
- name: "nightly-session-compaction"
|
||||||
|
declarationKey: "nightly-session-compaction"
|
||||||
|
description: "Nightly multi-agent session compaction and SQLite WAL checkpointing"
|
||||||
|
schedule:
|
||||||
|
kind: "cron"
|
||||||
|
expr: "45 4 * * *"
|
||||||
|
tz: "Europe/Berlin"
|
||||||
|
session: "isolated"
|
||||||
|
delivery: "none"
|
||||||
|
command: "python3 /home/node/.openclaw/scripts/compact_sessions.py"
|
||||||
|
|
||||||
|
# 2. Nightly Garmin Connect Mirror
|
||||||
|
- name: "garmin-nightly-mirror"
|
||||||
|
declarationKey: "garmin-nightly-mirror"
|
||||||
|
description: "Nächtlicher deterministischer Spiegel der Garmin Connect Daten (Aktivitäten, Daily Summary, Schlaf) in Git JSONL"
|
||||||
|
schedule:
|
||||||
|
kind: "cron"
|
||||||
|
expr: "30 3 * * *"
|
||||||
|
tz: "Europe/Berlin"
|
||||||
|
agent: "fitness"
|
||||||
|
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
|
||||||
|
session: "isolated"
|
||||||
|
channel: "telegram"
|
||||||
|
to: "-1004326921510"
|
||||||
|
threadId: "20"
|
||||||
|
announce: true
|
||||||
|
message: |
|
||||||
|
Führe den nächtlichen Garmin Connect Sync durch.
|
||||||
|
Vorgehen:
|
||||||
|
1. Führe den Garmin-Sync-Befehl aus:
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 --data-dir /home/node/.openclaw/wiki/main/data
|
||||||
|
2. Finale Antwort:
|
||||||
|
- Bei Erfolg: Gib exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam in der Nacht).
|
||||||
|
- Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow.
|
||||||
|
|
||||||
|
# 4. Weekly Autonomous System & Error Audit
|
||||||
|
- name: "itguy-weekly-health-audit"
|
||||||
|
declarationKey: "itguy-weekly-health-audit"
|
||||||
|
description: "Wöchentlicher Self-Audit der OpenClaw-Logs, Tool-Failures und Session-Transkripte mit Git-Commit-Korrelation"
|
||||||
|
schedule:
|
||||||
|
kind: "cron"
|
||||||
|
expr: "0 3 * * 0"
|
||||||
|
tz: "Europe/Berlin"
|
||||||
|
agent: "itguy"
|
||||||
|
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
|
||||||
|
session: "isolated"
|
||||||
|
channel: "telegram"
|
||||||
|
to: "-1004326921510"
|
||||||
|
threadId: "119"
|
||||||
|
announce: true
|
||||||
|
message: |
|
||||||
|
Führe den wöchentlichen OpenClaw System- und Log-Audit durch.
|
||||||
|
Vorgehen:
|
||||||
|
1. Führe den Audit-Befehl aus:
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push
|
||||||
|
2. Analysiere die Ausgabe und korreliere eventuelle Fehler-Cluster mit den jüngsten Commits in infrapuzzle, mop-skills und knowledge-wiki.
|
||||||
|
3. Finale Antwort:
|
||||||
|
- Wenn NEUE Probleme gefunden oder Tasks in tasks/antigravity.md eingetragen wurden: Fasse die Ergebnisse kurz, präzise und lösungsorientiert zusammen (betroffene Tools, vermutete Ursache, Link zur Task-Queue) und gib diese Zusammenfassung als finale Antwort aus.
|
||||||
|
- Wenn das System gesund ist und KEINE neuen Tasks erzeugt wurden: Gib als finale Antwort exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam).
|
||||||
|
|
||||||
|
# 5. Weekly AI Roundup (Recherche)
|
||||||
|
- name: "weekly-ai-roundup"
|
||||||
|
declarationKey: "weekly-ai-roundup:recherche"
|
||||||
|
description: "Wöchentlicher AI-Roundup und Tech-Scanning für Topic #Research"
|
||||||
|
schedule:
|
||||||
|
kind: "cron"
|
||||||
|
expr: "30 6 * * 5"
|
||||||
|
tz: "Europe/Berlin"
|
||||||
|
agent: "recherche"
|
||||||
|
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
|
||||||
|
session: "isolated"
|
||||||
|
channel: "telegram"
|
||||||
|
to: "-1004326921510"
|
||||||
|
threadId: "117"
|
||||||
|
announce: true
|
||||||
|
message: |
|
||||||
|
🗓️ WÖCHENTLICHER AI-ROUNDUP (Freitag). Führe den in sources.md im Workspace definierten Ablauf aus und poste das Ergebnis als EINE kompakte Telegram-Nachricht in diesen Topic (#Research).
|
||||||
|
|
||||||
|
1) Hacker News: Algolia API – https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=points>150,created_at_i>{Unix-Timestamp von vor 7 Tagen}&hitsPerPage=100 → daraus die KI-relevanten Top-Stories nach Punkten ziehen.
|
||||||
|
2) Frontier-Labs prüfen: OpenAI, Anthropic, Google DeepMind, Meta AI, Mistral, xAI.
|
||||||
|
3) China-Labs prüfen: GLM/Z.ai, Qwen, DeepSeek, Moonshot/Kimi, MiniMax.
|
||||||
|
4) Tracker: Artificial Analysis Releases, Hugging Face Trending, OpenRouter Rankings, LMArena.
|
||||||
|
5) Newsletter/Blogs: The Batch, Import AI, Interconnects, Ahead of AI, Simon Willison, TLDR AI.
|
||||||
|
6) GitHub: Trending (weekly) + neue Releases der Kern-Orgs (openai, anthropics, google-deepmind, QwenLM, deepseek-ai, zai-org, meta-llama, mistralai).
|
||||||
|
7) YouTube nur als Hype-Indikator (Titel/Snippets), KEINE Transkripte.
|
||||||
|
|
||||||
|
Ausgabe (Deutsch, Markdown, KEINE Tabellen):
|
||||||
|
- Top-3-Highlights der Woche (je 1 Satz + Link)
|
||||||
|
- Danach Bullets je Kategorie: ① Frontier-Modelle · ② China-Modelle · ③ Tools/Infra/Procedures · ④ Trends (HN/GitHub/YouTube), je Eintrag genau 1 Quell-Link
|
||||||
|
- Scope 60/40 (Modelle/Tools). Wenn eine Kategorie nichts Neues hat: kurz 'nichts Neues'.
|
||||||
|
- Am Ende NUR falls nötig eine Zeile: welche Quelle in sources.md ergänzt/entfernt werden sollte.
|
||||||
|
|
||||||
|
Nutze web_search/web_fetch. Effort: medium.
|
||||||
|
|
||||||
|
# 6. Weekly Bridge Tracker (Holzgartensteg)
|
||||||
|
- name: "Recherche: Holzgartensteg (wöchentlich)"
|
||||||
|
declarationKey: "recherche:holzgartensteg"
|
||||||
|
description: "Wöchentlicher Status-Check zum Holzgartensteg Regensburg via ALLRIS & Web"
|
||||||
|
schedule:
|
||||||
|
kind: "cron"
|
||||||
|
expr: "0 16 * * 5"
|
||||||
|
tz: "Europe/Berlin"
|
||||||
|
agent: "main"
|
||||||
|
session: "isolated"
|
||||||
|
channel: "telegram"
|
||||||
|
to: "306373425"
|
||||||
|
announce: true
|
||||||
|
message: |
|
||||||
|
Wöchentlicher Recherche-Task (Holzgartensteg, Stadt Regensburg).
|
||||||
|
|
||||||
|
AUFGABE: Prüfe, ob es NEUIGKEITEN zum geplanten Holzgartensteg (geplante Fuß-/Radbrücke zwischen Grieser Spitz und Maria-Beer-Platz / Weichs) gibt — seit dem letzten Check bzw. den letzten ~7 Tagen.
|
||||||
|
|
||||||
|
QUELLEN:
|
||||||
|
1) ALLRIS via Skill 'regensburg-stadtrat': Script /home/node/mop-skills/mop-skills/skills/regensburg-stadtrat/scripts/regensburg_ris.py — Befehle: search "Holzgartensteg" --limit 30 ; calendar --days 30 ; vorlage <ID> ; session <ID>. Achte auf NEUE Vorlagen, Beschlüsse, Anfragen und Sitzungstermine (Ausschuss für Stadtplanung, Verkehr und Wohnungsfragen / Stadtrat).
|
||||||
|
2) Lokale Medien via web_search: regensburg-digital.de, idowa.de, mittelbayerische.de, tvaktuell.com, regensburger-nachrichten.de.
|
||||||
|
|
||||||
|
FOKUS: neue Vorlagen/Beschlüsse, Ausschuss-/Stadtrattermine, Mittel/Budget, Planungsstand (Variante, Kosten, Termin), politische Positionen.
|
||||||
|
|
||||||
|
BEKANNTER STAND (nicht wiederholen, nur Abweichungen melden): 2020 aus Investitionsprogramm gestrichen ('beerdigt'); Comeback ab 2025 (Grüne/Verbände); Kommunalwahl 2026 mehrere Parteien dafür; Haushalt 2026: ~100.000 EUR Planungsmittel im Investitionsprogramm, Stadtrat-Beschluss 26.02.2026, Genehmigung Regierung der Oberpfalz 06.03.2026; Koalitionsvereinbarung 2026 mit Wiederaufnahme der Planungen.
|
||||||
|
|
||||||
|
AUSGABE: Kurz auf Deutsch an Moritz. Falls echte Neuigkeiten: 3-6 Bullets mit Quelle+Link. Falls NICHTS Neues: eine kurze Zeile, z.B. 'Holzgartensteg: keine neuen Entwicklungen diese Woche.' Kein Hintergrund-Recycling.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: PrometheusRule
|
||||||
|
metadata:
|
||||||
|
name: openclaw-alerts
|
||||||
|
namespace: openclaw
|
||||||
|
labels:
|
||||||
|
release: prometheus-operator
|
||||||
|
app.kubernetes.io/name: openclaw
|
||||||
|
spec:
|
||||||
|
groups:
|
||||||
|
- name: openclaw.rules
|
||||||
|
rules:
|
||||||
|
- alert: OpenClawPodNotReady
|
||||||
|
expr: kube_pod_status_ready{namespace="openclaw", pod="openclaw-0", condition="true"} == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "OpenClaw pod openclaw-0 is not ready"
|
||||||
|
description: "Pod openclaw-0 in namespace openclaw has been unready for more than 5 minutes. Telegram and background agents may be offline."
|
||||||
|
- alert: OpenClawCrashLooping
|
||||||
|
expr: increase(kube_pod_container_status_restarts_total{namespace="openclaw", pod="openclaw-0"}[15m]) >= 2
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "OpenClaw container restart detected"
|
||||||
|
description: "Container {{ $labels.container }} in pod openclaw-0 has restarted {{ $value }} times in the last 15 minutes."
|
||||||
|
- alert: OpenClawDataVolumeLow
|
||||||
|
expr: kubelet_volume_stats_available_bytes{namespace="openclaw", persistentvolumeclaim="openclaw-data"} / kubelet_volume_stats_capacity_bytes{namespace="openclaw", persistentvolumeclaim="openclaw-data"} < 0.15
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "OpenClaw PVC data volume is low on space"
|
||||||
|
description: "PersistentVolumeClaim openclaw-data has less than 15% space available."
|
||||||
Binary file not shown.
@@ -0,0 +1,205 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== OpenClaw Dependency & Runtime Bootstrap ==="
|
||||||
|
export PATH="/home/node/.openclaw/bin:/home/node/.openclaw/.local/bin:$PATH"
|
||||||
|
|
||||||
|
SCRIPTS_DIR="/home/node/.openclaw/scripts"
|
||||||
|
mkdir -p /home/node/.openclaw/bin "$SCRIPTS_DIR" /home/node/.openclaw/.config
|
||||||
|
touch /home/node/.openclaw/.config/keep
|
||||||
|
|
||||||
|
# 1. Sanitize legacy config keys in openclaw.json
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
for (const file of ["/home/node/.openclaw/openclaw.json", "/home/openclaw/.openclaw/openclaw.json"]) {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
if (cfg.browser) {
|
||||||
|
delete cfg.browser.remoteCdpTimeoutMs;
|
||||||
|
if (cfg.browser.profiles) {
|
||||||
|
if (cfg.browser.profiles.default) delete cfg.browser.profiles.default.color;
|
||||||
|
if (cfg.browser.profiles.chrome) delete cfg.browser.profiles.chrome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cfg.gateway && cfg.gateway.controlUi) {
|
||||||
|
delete cfg.gateway.controlUi.dangerouslyDisableDeviceAuth;
|
||||||
|
}
|
||||||
|
if (!cfg.agents) cfg.agents = {};
|
||||||
|
cfg.agents.ownership = "explicit";
|
||||||
|
if (cfg.agents.entries && cfg.agents.entries.main) {
|
||||||
|
delete cfg.agents.entries.main.default;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
|
||||||
|
console.log("Sanitized config file:", file);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to sanitize", file, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' || true
|
||||||
|
|
||||||
|
# 2. Run pre-flight session recovery
|
||||||
|
echo "Running pre-flight self-healing..."
|
||||||
|
python3 "$SCRIPTS_DIR/recover_sessions.py" || true
|
||||||
|
|
||||||
|
# 3. Doctor fix
|
||||||
|
echo "Running openclaw doctor --fix to migrate runtime config on PVC..."
|
||||||
|
openclaw doctor --fix || true
|
||||||
|
|
||||||
|
# 4. Garmin OAuth tokens bootstrap / preserve
|
||||||
|
if [ ! -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ] && [ -n "$GARMIN_TOKENS_BASE64" ]; then
|
||||||
|
echo "Bootstrapping initial Garmin OAuth tokens from secret..."
|
||||||
|
mkdir -p /home/node/.openclaw/garmin_tokens
|
||||||
|
echo "$GARMIN_TOKENS_BASE64" | base64 -d > /home/node/.openclaw/garmin_tokens/garmin_tokens.json
|
||||||
|
chmod 700 /home/node/.openclaw/garmin_tokens
|
||||||
|
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
|
||||||
|
elif [ -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ]; then
|
||||||
|
echo "Preserving live Garmin OAuth tokens on PVC..."
|
||||||
|
chmod 700 /home/node/.openclaw/garmin_tokens
|
||||||
|
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Cleanup legacy binaries and superseded local skills
|
||||||
|
rm -rf /home/node/.openclaw/bin/whisper* /home/node/.openclaw/bin/.whisper* /home/node/.openclaw/scripts/whisper* /home/node/.openclaw/models/whisper || true
|
||||||
|
rm -rf /home/node/.openclaw/skills/memobird /home/node/.openclaw/skills/nerd-report /home/node/.openclaw/skills/rvv-regensburg /home/node/.openclaw/skills/skill-creator || true
|
||||||
|
rm -rf /home/node/.openclaw/workspace/skills /home/node/.openclaw/workspace-koch/skills /home/node/.openclaw/workspace-recherche/skills || true
|
||||||
|
|
||||||
|
# 6. Ensure tools.allow and models.providers.openrouter.apiKey unset
|
||||||
|
openclaw config unset tools.allow || true
|
||||||
|
openclaw config unset models.providers.openrouter.apiKey || true
|
||||||
|
openclaw config unset agents.entries.mila_tracker.tools.profile || true
|
||||||
|
openclaw config unset agents.entries.nutrition_tracker.tools.profile || true
|
||||||
|
|
||||||
|
# 7. Decouple hardlinked bootstrap files
|
||||||
|
for dir in /home/node/.openclaw/workspace-*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
for file in USER.md MEMORY.md; do
|
||||||
|
if [ -f "/home/node/.openclaw/workspace/$file" ]; then
|
||||||
|
cp --remove-destination "/home/node/.openclaw/workspace/$file" "$dir/$file"
|
||||||
|
chown node:node "$dir/$file" 2>/dev/null || true
|
||||||
|
chmod 664 "$dir/$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 8. Install CLI binaries
|
||||||
|
if [ ! -f "/home/node/.openclaw/bin/openhue.real" ]; then
|
||||||
|
echo "Installing openhue CLI binary..."
|
||||||
|
curl -sL https://github.com/openhue/openhue-cli/releases/download/0.24/openhue_Linux_x86_64.tar.gz | tar -xz -C /home/node/.openclaw/bin openhue
|
||||||
|
mv /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
|
||||||
|
printf '#!/bin/sh\nexport HOME=/home/node/.openclaw\nexec /home/node/.openclaw/bin/openhue.real "$@"\n' > /home/node/.openclaw/bin/openhue
|
||||||
|
chmod +x /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "/home/node/.openclaw/bin/summarize" ]; then
|
||||||
|
echo "Installing summarize CLI via npm..."
|
||||||
|
npm install -g --prefix /home/node/.openclaw @steipete/summarize || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "/home/node/.openclaw/bin/mcp-server-github" ]; then
|
||||||
|
echo "Installing @modelcontextprotocol/server-github via npm..."
|
||||||
|
npm install -g --prefix /home/node/.openclaw @modelcontextprotocol/server-github || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "/home/node/.openclaw/bin/kubectl" ]; then
|
||||||
|
echo "Installing kubectl CLI binary..."
|
||||||
|
curl -fsSL https://dl.k8s.io/release/v1.31.0/bin/linux/amd64/kubectl -o /home/node/.openclaw/bin/kubectl
|
||||||
|
chmod +x /home/node/.openclaw/bin/kubectl
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "/home/node/.openclaw/bin/jq" ]; then
|
||||||
|
echo "Installing jq CLI binary..."
|
||||||
|
curl -fsSL https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 -o /home/node/.openclaw/bin/jq
|
||||||
|
chmod +x /home/node/.openclaw/bin/jq
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "/home/node/.openclaw/bin/file" ]; then
|
||||||
|
echo "Setting up file CLI helper..."
|
||||||
|
cp "$SCRIPTS_DIR/file_cli.py" /home/node/.openclaw/bin/file
|
||||||
|
chmod +x /home/node/.openclaw/bin/file
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x "/home/node/.openclaw/.local/bin/python3" ]; then
|
||||||
|
/home/node/.openclaw/bin/uv pip install --break-system-packages --python /home/node/.openclaw/.local/bin/python3 pillow google-api-python-client pypdf || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 9. Clone or pull infrapuzzle repository to PVC
|
||||||
|
mkdir -p /home/node/.openclaw/repos
|
||||||
|
if [ ! -d "/home/node/.openclaw/repos/infrapuzzle/.git" ]; then
|
||||||
|
echo "Cloning infrapuzzle repository to PVC..."
|
||||||
|
git clone --depth 1 "https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/infrapuzzle.git" /home/node/.openclaw/repos/infrapuzzle || true
|
||||||
|
else
|
||||||
|
echo "Pulling latest infrapuzzle commits on PVC..."
|
||||||
|
git -C /home/node/.openclaw/repos/infrapuzzle pull origin master || git -C /home/node/.openclaw/repos/infrapuzzle pull origin main || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 10. Seed and maintain persistent mcp-venv
|
||||||
|
if [ ! -d "/home/node/.openclaw/mcp-venv" ]; then
|
||||||
|
echo "Seeding persistent mcp-venv..."
|
||||||
|
uv venv --python 3.12 /home/node/.openclaw/mcp-venv
|
||||||
|
uv pip install --python /home/node/.openclaw/mcp-venv \
|
||||||
|
prometheus-mcp-server \
|
||||||
|
ha-mcp \
|
||||||
|
chuk-mcp-open-meteo \
|
||||||
|
gitea-mcp \
|
||||||
|
git+https://github.com/Taxuspt/garmin_mcp \
|
||||||
|
"mcp>=2.0" \
|
||||||
|
filetype \
|
||||||
|
google-api-python-client \
|
||||||
|
google-auth \
|
||||||
|
bring-api \
|
||||||
|
requests \
|
||||||
|
beautifulsoup4 \
|
||||||
|
youtube-transcript-api
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "/home/node/.openclaw/mcp-venv" ]; then
|
||||||
|
python3 -c 'import os; p="/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/mcp/server/fastmcp.py"; open(p, "w").write("import os\nfrom fastmcp import FastMCP as _FastMCP, Context\n\nclass FastMCP(_FastMCP):\n def __init__(self, name=\"MCP Server\", host=None, port=None, **kwargs):\n if host is not None: os.environ[\"FASTMCP_HOST\"] = str(host)\n if port is not None: os.environ[\"FASTMCP_PORT\"] = str(port)\n super().__init__(name=name, **kwargs)\n")' || true
|
||||||
|
python3 "$SCRIPTS_DIR/patch_garmin_mcp.py" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 11. Extensions & Plugins
|
||||||
|
if [ ! -d "/home/node/.openclaw/extensions/perplexity" ]; then
|
||||||
|
echo "Installing perplexity plugin..."
|
||||||
|
openclaw plugins install clawhub:@openclaw/perplexity-plugin --accept-capabilities || true
|
||||||
|
fi
|
||||||
|
echo "Enabling memory-wiki plugin..."
|
||||||
|
openclaw plugins enable memory-wiki || true
|
||||||
|
|
||||||
|
echo "Patching perplexity web search runtime for chat completions compatibility..."
|
||||||
|
node "$SCRIPTS_DIR/patch_perplexity.mjs" || true
|
||||||
|
|
||||||
|
# 12. Ensure OpenRouter auth profile in state DB
|
||||||
|
echo "Ensuring OpenRouter auth profile in state DB..."
|
||||||
|
node --input-type=module -e '
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
const key = process.env.OPENROUTER_API_KEY;
|
||||||
|
if (key) {
|
||||||
|
try {
|
||||||
|
const dbPath = "/home/node/.openclaw/state/openclaw.sqlite";
|
||||||
|
const db = new DatabaseSync(dbPath);
|
||||||
|
db.prepare("CREATE TABLE IF NOT EXISTS config_machine_state (state_key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)").run();
|
||||||
|
const row = db.prepare("SELECT value_json FROM config_machine_state WHERE state_key = \x27authProfiles.store\x27").get();
|
||||||
|
let store = row?.value_json ? JSON.parse(row.value_json) : { version: 1, profiles: {} };
|
||||||
|
if (!store.profiles) store.profiles = {};
|
||||||
|
store.profiles["openrouter:default"] = {
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openrouter",
|
||||||
|
key: key
|
||||||
|
};
|
||||||
|
db.prepare("INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (\x27authProfiles.store\x27, ?, ?) ON CONFLICT(state_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
|
||||||
|
.run(JSON.stringify(store), Date.now());
|
||||||
|
console.log("Synchronized openrouter auth profile in state DB.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not sync auth profile:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' || true
|
||||||
|
|
||||||
|
# 13. Pre-warm SQLite page cache
|
||||||
|
echo "Pre-warming SQLite database files into OS page cache for instant cold start..."
|
||||||
|
cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "=== Bootstrap Complete ==="
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Nightly multi-agent session maintenance and compaction script for OpenClaw.
|
||||||
|
1. Pre-warms OS page cache for SQLite databases on spinning disks.
|
||||||
|
2. Recovers any wedged or locked sessions via recover_sessions.py.
|
||||||
|
3. Prunes abandoned isolated cron sessions (>48h old) to prevent token/state bloat.
|
||||||
|
4. Tier A Hard-Reset: Resets koch, hebamme, and shopping topics to 0 tokens (clean archive).
|
||||||
|
5. Tier B Smart-Compaction: Compacts active sessions exceeding token thresholds (5s pacing).
|
||||||
|
6. Checkpoints SQLite WAL journals across all agent databases to disk.
|
||||||
|
7. Optimizes SQLite query planner without blocking full VACUUM.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
THRESHOLD_TOKENS = 15000
|
||||||
|
THRESHOLD_TRANSCRIPT_BYTES = 200000
|
||||||
|
THRESHOLD_EVENT_COUNT = 30
|
||||||
|
CRON_MAX_AGE_HOURS = 48
|
||||||
|
RPC_TIMEOUT_MS = 180000
|
||||||
|
|
||||||
|
# Tracking Channels: Deterministic sliding-window (mila_tracker, nutrition_tracker).
|
||||||
|
# Strict sliding window of ~5-8 events (2-3 turns max), 0 LLM tokens, instant truncation.
|
||||||
|
TRACKING_TARGETS = [
|
||||||
|
("mila_tracker", "agent:mila_tracker:telegram:group:-1004326921510:topic:2796"),
|
||||||
|
("nutrition_tracker", "agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797"),
|
||||||
|
]
|
||||||
|
MAX_TRACKING_EVENTS = 8
|
||||||
|
MAX_TRACKING_LINES = 8
|
||||||
|
|
||||||
|
# Tier A: High-frequency interaction topics (koch, hebamme, shopping).
|
||||||
|
# Deep-compaction keeps a compact summary + latest 2 turns without completely wiping conversational memory.
|
||||||
|
TIER_A_TARGETS = [
|
||||||
|
("koch", "agent:koch:telegram:group:-1004326921510:topic:21"),
|
||||||
|
("hebamme", "agent:hebamme:telegram:group:-1004326921510:topic:22"),
|
||||||
|
("shopping", "agent:shopping:telegram:group:-1004326921510:topic:115"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def prewarm_page_cache():
|
||||||
|
print("=== Pre-warming SQLite databases and Knowledge Wiki into OS page cache ===")
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; "
|
||||||
|
"find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true",
|
||||||
|
shell=True,
|
||||||
|
timeout=90.0
|
||||||
|
)
|
||||||
|
print(f"Page cache pre-warmed in {time.time() - t0:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Page cache pre-warming warning: {e}")
|
||||||
|
|
||||||
|
def prune_stale_cron_sessions():
|
||||||
|
print(f"=== Pruning isolated cron sessions older than {CRON_MAX_AGE_HOURS}h ===")
|
||||||
|
cutoff_ms = int(time.time() * 1000) - (CRON_MAX_AGE_HOURS * 3600 * 1000)
|
||||||
|
total_pruned = 0
|
||||||
|
total_tokens_reclaimed = 0
|
||||||
|
|
||||||
|
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
|
||||||
|
agent = db.split("/")[5]
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db, timeout=30.0)
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON;")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type=\"table\"")
|
||||||
|
all_tables = set(r[0] for r in cursor.fetchall())
|
||||||
|
|
||||||
|
cursor.execute("SELECT session_key, current_session_id, updated_at, entry_json FROM session_nodes WHERE session_key LIKE \"%:cron:%\"")
|
||||||
|
stale = []
|
||||||
|
for k, sid, up, ej in cursor.fetchall():
|
||||||
|
if up and up < cutoff_ms:
|
||||||
|
tok = 0
|
||||||
|
if ej:
|
||||||
|
try:
|
||||||
|
tok = json.loads(ej).get("totalTokens", 0) or 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
stale.append((k, sid, tok))
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
tokens_reclaimed = sum(t[2] for t in stale)
|
||||||
|
for k, sid, _ in stale:
|
||||||
|
for t in ["trajectory_runtime_events", "transcript_events", "transcript_event_identities", "session_transcript_active_events", "transcript_rewrite_watermarks", "session_transcript_index_state", "session_transcript_fts", "session_conversations"]:
|
||||||
|
if t in all_tables and sid:
|
||||||
|
cursor.execute(f"DELETE FROM {t} WHERE session_id = ?", (sid,))
|
||||||
|
for t in ["session_participants", "heartbeat_outcomes", "session_progress_cards", "board_widgets", "board_tabs"]:
|
||||||
|
if t in all_tables:
|
||||||
|
cursor.execute(f"DELETE FROM {t} WHERE session_key = ?", (k,))
|
||||||
|
if "memory_entry_origins" in all_tables:
|
||||||
|
cursor.execute("DELETE FROM memory_entry_origins WHERE session_key = ? OR session_id = ?", (k, sid or ""))
|
||||||
|
if "session_windows" in all_tables:
|
||||||
|
cursor.execute("DELETE FROM session_windows WHERE session_key = ? OR session_id = ?", (k, sid or ""))
|
||||||
|
cursor.execute("DELETE FROM session_nodes WHERE session_key = ?", (k,))
|
||||||
|
conn.commit()
|
||||||
|
total_pruned += len(stale)
|
||||||
|
total_tokens_reclaimed += tokens_reclaimed
|
||||||
|
print(f" [{agent}] Pruned {len(stale)} dead cron sessions ({tokens_reclaimed} tokens reclaimed)")
|
||||||
|
|
||||||
|
# Unconditionally resolve any dangling foreign key references across all tables
|
||||||
|
while True:
|
||||||
|
cursor.execute("PRAGMA foreign_key_check;")
|
||||||
|
violations = cursor.fetchall()
|
||||||
|
if not violations:
|
||||||
|
break
|
||||||
|
for table, rowid, parent, fkid in violations:
|
||||||
|
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{agent}] Pruning error: {e}")
|
||||||
|
|
||||||
|
# Clear resolved quarantine entries if all databases are healthy
|
||||||
|
try:
|
||||||
|
q_path = "/home/node/.openclaw/state/openclaw-quarantine.sqlite"
|
||||||
|
if os.path.exists(q_path):
|
||||||
|
q_conn = sqlite3.connect(q_path)
|
||||||
|
q_cur = q_conn.cursor()
|
||||||
|
q_cur.execute("DELETE FROM quarantined_databases")
|
||||||
|
q_conn.commit()
|
||||||
|
q_conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Quarantine cleanup error: {e}")
|
||||||
|
|
||||||
|
print(f"Cron pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens reclaimed.")
|
||||||
|
|
||||||
|
def compact_tracking_sessions():
|
||||||
|
print("=== Tracking Channels Sliding-Window Maintenance (mila_tracker, nutrition_tracker) ===")
|
||||||
|
for agent, key in TRACKING_TARGETS:
|
||||||
|
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db_path, timeout=5.0)
|
||||||
|
row = con.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
|
||||||
|
if not row or not row[0]:
|
||||||
|
con.close()
|
||||||
|
print(f" [{agent}] No active session node found for {key}.")
|
||||||
|
continue
|
||||||
|
sid = row[0]
|
||||||
|
cnt = con.execute("SELECT count(*) FROM transcript_events WHERE session_id=?", (sid,)).fetchone()[0]
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
if cnt > MAX_TRACKING_EVENTS:
|
||||||
|
print(f" [{agent}] Session {key} has {cnt} events (> {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES} lines...")
|
||||||
|
cmd = [
|
||||||
|
"openclaw", "sessions", "compact", key,
|
||||||
|
"--agent", agent,
|
||||||
|
"--max-lines", str(MAX_TRACKING_LINES),
|
||||||
|
"--timeout", "30000"
|
||||||
|
]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode == 0:
|
||||||
|
print(f" ✓ Truncated successfully: {r.stdout.strip()}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Truncation failed: {r.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
print(f" [{agent}] Session {key} has {cnt} events (<= {MAX_TRACKING_EVENTS}). Clean.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ [{agent}] Error during tracking compaction: {e}")
|
||||||
|
|
||||||
|
def compact_tier_a_sessions():
|
||||||
|
print("=== Tier A: Deep-Compaction (koch, hebamme, shopping) ===")
|
||||||
|
for agent, key in TIER_A_TARGETS:
|
||||||
|
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db_path, timeout=10.0)
|
||||||
|
row = con.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
|
||||||
|
con.close()
|
||||||
|
if not row or not row[0]:
|
||||||
|
print(f" [Tier A] {agent} ({key}) already at 0 (no active session).")
|
||||||
|
continue
|
||||||
|
ev_cnt, ev_bytes = get_session_metrics(agent, key, row[0])
|
||||||
|
if ev_cnt <= 2 and ev_bytes < 20000:
|
||||||
|
print(f" [Tier A] {agent} ({key}) has only {ev_cnt} events ({ev_bytes} bytes), skipping.")
|
||||||
|
continue
|
||||||
|
print(f" [Tier A] Deep-compacting {agent} ({key}) [{ev_cnt} events, {ev_bytes//1024}KB]...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["openclaw", "sessions", "compact", key, "--agent", agent, "--timeout", str(RPC_TIMEOUT_MS)],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
print(f" ✓ Deep-compacted: {r.stdout.strip()}")
|
||||||
|
elif "Already compacted" in r.stderr or "Already compacted" in r.stdout:
|
||||||
|
print(f" ✓ Already compacted.")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Standard compact error: {r.stderr.strip()}; retrying with --max-lines 40...")
|
||||||
|
fb = subprocess.run(
|
||||||
|
["openclaw", "sessions", "compact", key, "--agent", agent, "--max-lines", "40", "--timeout", str(RPC_TIMEOUT_MS)],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if fb.returncode == 0:
|
||||||
|
print(f" ✓ Truncation fallback success: {fb.stdout.strip()}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Truncation fallback failed: {fb.stderr.strip()}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Exception deep-compacting {agent}: {e}")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def get_session_metrics(agent, key, sid=None):
|
||||||
|
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return 0, 0
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db_path, timeout=5.0)
|
||||||
|
cur = con.cursor()
|
||||||
|
if not sid:
|
||||||
|
row = cur.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
|
||||||
|
sid = row[0] if row else None
|
||||||
|
if not sid:
|
||||||
|
con.close()
|
||||||
|
return 0, 0
|
||||||
|
ev_cnt, ev_bytes = cur.execute(
|
||||||
|
"SELECT count(*), coalesce(sum(length(event_json)), 0) FROM transcript_events WHERE session_id=?",
|
||||||
|
(sid,)
|
||||||
|
).fetchone()
|
||||||
|
con.close()
|
||||||
|
return ev_cnt, ev_bytes
|
||||||
|
except Exception:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
def compact_active_sessions():
|
||||||
|
excluded_keys = set(t[1] for t in TIER_A_TARGETS) | set(t[1] for t in TRACKING_TARGETS)
|
||||||
|
print(f"=== Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens / {THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===")
|
||||||
|
res = subprocess.run(["openclaw", "sessions", "--all-agents", "--json"], capture_output=True, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
print(f"Failed to list sessions: {res.stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
sessions = json.loads(res.stdout).get("sessions", [])
|
||||||
|
compacted = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for s in sessions:
|
||||||
|
key = s.get("key", "")
|
||||||
|
agent = s.get("agentId") or "main"
|
||||||
|
tokens = s.get("totalTokens") or 0
|
||||||
|
sid = s.get("sessionId")
|
||||||
|
|
||||||
|
# Skip Tier A and Tracking agents as they have dedicated routines
|
||||||
|
if key in excluded_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Target main sessions and all telegram sessions (topics and direct)
|
||||||
|
is_target = (
|
||||||
|
key == "agent:main:main" or
|
||||||
|
":telegram:" in key or
|
||||||
|
(key.startswith("agent:") and key.endswith(":main"))
|
||||||
|
)
|
||||||
|
if is_target and not key.endswith(":heartbeat"):
|
||||||
|
ev_cnt, ev_bytes = get_session_metrics(agent, key, sid)
|
||||||
|
needs_compaction = (
|
||||||
|
tokens >= THRESHOLD_TOKENS or
|
||||||
|
ev_bytes >= THRESHOLD_TRANSCRIPT_BYTES or
|
||||||
|
(tokens == 0 and ev_cnt >= THRESHOLD_EVENT_COUNT)
|
||||||
|
)
|
||||||
|
if needs_compaction:
|
||||||
|
stat_str = f"{tokens} tokens" if tokens else f"{ev_bytes//1024}KB / {ev_cnt} events"
|
||||||
|
print(f"Compacting {agent} ({stat_str}) -> {key}...")
|
||||||
|
c_res = subprocess.run(
|
||||||
|
["openclaw", "sessions", "compact", key, "--agent", agent, "--timeout", str(RPC_TIMEOUT_MS)],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if c_res.returncode == 0:
|
||||||
|
print(f" ✓ {c_res.stdout.strip()}")
|
||||||
|
compacted += 1
|
||||||
|
elif "Already compacted" in c_res.stderr or "Already compacted" in c_res.stdout:
|
||||||
|
print(f" ✓ Already compacted.")
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ {c_res.stderr.strip()}; retrying with --max-lines 80 fallback...")
|
||||||
|
fb_res = subprocess.run(
|
||||||
|
["openclaw", "sessions", "compact", key, "--agent", agent, "--max-lines", "80", "--timeout", str(RPC_TIMEOUT_MS)],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if fb_res.returncode == 0:
|
||||||
|
print(f" ✓ (fallback) {fb_res.stdout.strip()}")
|
||||||
|
compacted += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ Fallback failed: {fb_res.stderr.strip()}")
|
||||||
|
time.sleep(5) # 5s pause to avoid rotational HDD I/O saturation
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
print(f"Compaction complete: {compacted} compacted, {skipped} skipped.")
|
||||||
|
|
||||||
|
def checkpoint_wal_journals():
|
||||||
|
print("=== Checkpointing SQLite WAL journals across all agent databases ===")
|
||||||
|
dbs = ["/home/node/.openclaw/state/openclaw.sqlite"] + glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")
|
||||||
|
wal_freed = 0
|
||||||
|
for db in dbs:
|
||||||
|
wal = db + "-wal"
|
||||||
|
if os.path.exists(wal):
|
||||||
|
sz = os.path.getsize(wal)
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db, timeout=30.0)
|
||||||
|
con.execute("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||||
|
con.close()
|
||||||
|
wal_freed += sz
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error checkpointing {db}: {e}")
|
||||||
|
print(f"WAL Checkpoint complete: {wal_freed / (1024*1024):.2f} MB merged to disk.")
|
||||||
|
|
||||||
|
def vacuum_and_optimize():
|
||||||
|
print("=== Running SQLite OPTIMIZE across all databases ===")
|
||||||
|
dbs = ["/home/node/.openclaw/state/openclaw.sqlite"] + glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")
|
||||||
|
for db in dbs:
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db, timeout=30.0)
|
||||||
|
con.execute("PRAGMA optimize;")
|
||||||
|
con.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error optimizing {db}: {e}")
|
||||||
|
print("SQLite optimization complete.")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
prewarm_page_cache()
|
||||||
|
try:
|
||||||
|
subprocess.run(["python3", "/home/node/.openclaw/scripts/recover_sessions.py"], check=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
prune_stale_cron_sessions()
|
||||||
|
compact_tracking_sessions()
|
||||||
|
compact_tier_a_sessions()
|
||||||
|
compact_active_sessions()
|
||||||
|
checkpoint_wal_journals()
|
||||||
|
vacuum_and_optimize()
|
||||||
|
prewarm_page_cache()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Lightweight sliding-window compaction for OpenClaw tracking channels:
|
||||||
|
- mila_tracker (topic 2796)
|
||||||
|
- nutrition_tracker (topic 2797)
|
||||||
|
|
||||||
|
Maintains a strict sliding window of ~5-8 transcript events (max 2-3 turns).
|
||||||
|
Uses native gateway truncation (--max-lines 8) requiring 0 LLM tokens and 0 external API calls.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAX_TRACKING_EVENTS = 8
|
||||||
|
MAX_TRACKING_LINES = 8
|
||||||
|
TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
TRACKING_TARGETS = [
|
||||||
|
("mila_tracker", "agent:mila_tracker:telegram:group:-1004326921510:topic:2796"),
|
||||||
|
("nutrition_tracker", "agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def check_and_compact_tracking():
|
||||||
|
compacted_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for agent, key in TRACKING_TARGETS:
|
||||||
|
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db_path, timeout=5.0)
|
||||||
|
row = con.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
|
||||||
|
if not row or not row[0]:
|
||||||
|
con.close()
|
||||||
|
continue
|
||||||
|
sid = row[0]
|
||||||
|
cnt = con.execute("SELECT count(*) FROM transcript_events WHERE session_id=?", (sid,)).fetchone()[0]
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
if cnt > MAX_TRACKING_EVENTS:
|
||||||
|
print(f"[{agent}] Session {key} has {cnt} events (threshold {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES} lines...")
|
||||||
|
cmd = [
|
||||||
|
"openclaw", "sessions", "compact", key,
|
||||||
|
"--agent", agent,
|
||||||
|
"--max-lines", str(MAX_TRACKING_LINES),
|
||||||
|
"--timeout", str(TIMEOUT_MS)
|
||||||
|
]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if res.returncode == 0:
|
||||||
|
print(f" ✓ [{agent}] Truncated successfully: {res.stdout.strip()}")
|
||||||
|
compacted_count += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ [{agent}] Truncation failed: {res.stderr.strip()}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{agent}] Error checking session: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if compacted_count > 0:
|
||||||
|
print(f"Tracking maintenance: {compacted_count} sessions truncated, {skipped_count} within window.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_and_compact_tracking()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import glob, json, os, sqlite3, sys
|
||||||
|
|
||||||
|
cfg_path = "/home/node/.openclaw/openclaw.json"
|
||||||
|
if os.path.exists(cfg_path):
|
||||||
|
try:
|
||||||
|
with open(cfg_path, "r") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
mem_search = cfg.get("memory", {}).get("search", {})
|
||||||
|
if mem_search.get("extraPaths"):
|
||||||
|
mem_search["extraPaths"] = []
|
||||||
|
with open(cfg_path, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
print("[dedup_subagents] Cleared top-level memory.search.extraPaths from openclaw.json (scoped to main only)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[dedup_subagents] Warning checking openclaw.json: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
|
||||||
|
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
|
||||||
|
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
|
||||||
|
if agent == "main":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db, timeout=10.0)
|
||||||
|
if os.path.exists(vec_path):
|
||||||
|
conn.enable_load_extension(True)
|
||||||
|
conn.load_extension(vec_path)
|
||||||
|
cur = conn.cursor()
|
||||||
|
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
|
||||||
|
if chk > 0:
|
||||||
|
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
|
||||||
|
for t in [
|
||||||
|
"memory_index_sources",
|
||||||
|
"memory_index_chunks",
|
||||||
|
"memory_index_chunk_recall_metadata",
|
||||||
|
"memory_index_chunk_provenance",
|
||||||
|
"memory_entry_origins",
|
||||||
|
"memory_embedding_cache",
|
||||||
|
"memory_index_chunks_vec"
|
||||||
|
]:
|
||||||
|
try: cur.execute(f"DELETE FROM {t}")
|
||||||
|
except Exception: pass
|
||||||
|
conn.commit()
|
||||||
|
cur.execute("VACUUM")
|
||||||
|
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/home/node/.openclaw/mcp-venv/bin/python3
|
||||||
|
import sys, os, mimetypes, filetype
|
||||||
|
|
||||||
|
def get_file_info(filepath, mime_only=False):
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return "cannot open (No such file or directory)", "cannot open (No such file or directory)"
|
||||||
|
if os.path.isdir(filepath):
|
||||||
|
return "directory", "inode/directory"
|
||||||
|
kind = filetype.guess(filepath)
|
||||||
|
if kind is not None:
|
||||||
|
return f"{kind.extension.upper()} image/media data ({kind.mime})", kind.mime
|
||||||
|
guessed_mime, _ = mimetypes.guess_type(filepath)
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
sample = f.read(4096)
|
||||||
|
if len(sample) == 0:
|
||||||
|
return "empty", "application/x-empty"
|
||||||
|
try:
|
||||||
|
sample.decode("utf-8")
|
||||||
|
if guessed_mime:
|
||||||
|
return f"{guessed_mime} text", guessed_mime
|
||||||
|
return "ASCII text", "text/plain"
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return "data", "application/octet-stream"
|
||||||
|
except Exception as e:
|
||||||
|
return f"cannot read ({e})", "application/octet-stream"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
brief, mime_only, files = False, False, []
|
||||||
|
for arg in args:
|
||||||
|
if arg in ("-b", "--brief"):
|
||||||
|
brief = True
|
||||||
|
elif arg in ("-i", "--mime", "--mime-type"):
|
||||||
|
mime_only = True
|
||||||
|
elif arg.startswith("-") and len(arg) > 1 and not arg.startswith("--"):
|
||||||
|
if "b" in arg: brief = True
|
||||||
|
if "i" in arg: mime_only = True
|
||||||
|
else:
|
||||||
|
files.append(arg)
|
||||||
|
if not files:
|
||||||
|
sys.stderr.write("Usage: file [-b] [-i|--mime-type] <file>...\n")
|
||||||
|
sys.exit(1)
|
||||||
|
for f in files:
|
||||||
|
desc, mime = get_file_info(f, mime_only)
|
||||||
|
res = mime if mime_only else desc
|
||||||
|
print(res if brief else f"{f}: {res}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execSync, spawn } from "node:child_process";
|
||||||
|
|
||||||
|
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
|
||||||
|
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
|
||||||
|
|
||||||
|
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
|
||||||
|
|
||||||
|
if (isGateway) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
console.log("[gateway-preflight] Initializing container self-healing preflight...");
|
||||||
|
|
||||||
|
// 1. Clean up stale gateway lock files from ungraceful kills
|
||||||
|
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith("gateway") && file.endsWith(".lock")) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clear false database quarantines
|
||||||
|
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
|
||||||
|
if (fs.existsSync(qPath)) {
|
||||||
|
try {
|
||||||
|
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
|
||||||
|
console.log("[gateway-preflight] Cleared database quarantine.");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
|
||||||
|
try {
|
||||||
|
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
|
||||||
|
try {
|
||||||
|
const pw0 = Date.now();
|
||||||
|
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
|
||||||
|
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 5. Recover any wedged running sessions left over from ungraceful container restart
|
||||||
|
try {
|
||||||
|
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[gateway-preflight] Session recovery warning:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Spawn background turn replay daemon if interrupted turns were detected
|
||||||
|
try {
|
||||||
|
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
|
||||||
|
console.log("[gateway-preflight] Spawning background turn replay daemon...");
|
||||||
|
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore"
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
openclaw-triage.py: Rapid zero-dependency incident triage for OpenClaw multi-agent sessions.
|
||||||
|
Directly inspects ground-truth SQLite session state and transcript events in < 1 second.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
TOPIC_TO_AGENT = {
|
||||||
|
"20": "fitness",
|
||||||
|
"21": "koch",
|
||||||
|
"22": "hebamme",
|
||||||
|
"51": "travel",
|
||||||
|
"115": "shopping",
|
||||||
|
"116": "regensburg",
|
||||||
|
"117": "recherche",
|
||||||
|
"118": "doktor",
|
||||||
|
"119": "itguy",
|
||||||
|
"164": "grabengasse_media",
|
||||||
|
"2796": "mila_tracker",
|
||||||
|
"2797": "nutrition_tracker",
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Rapid OpenClaw multi-agent session triage.")
|
||||||
|
parser.add_argument("--topic", "-t", help="Telegram forum topic ID (e.g. 21, 22, 117)")
|
||||||
|
parser.add_argument("--agent", "-a", help="Agent identifier (e.g. koch, hebamme, recherche)")
|
||||||
|
parser.add_argument("--limit", "-n", type=int, default=10, help="Number of recent events to display (default: 10)")
|
||||||
|
parser.add_argument("--db-root", default="/home/node/.openclaw/agents", help="Root path of agent SQLite databases")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def triage_agent_session(agent, topic_id=None, limit=10, db_root="/home/node/.openclaw/agents"):
|
||||||
|
db_path = os.path.join(db_root, agent, "agent", "openclaw-agent.sqlite")
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"[-] Database not found for agent '{agent}': {db_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db_path, timeout=5.0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[-] Failed to connect to SQLite DB for agent '{agent}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
cur = con.cursor()
|
||||||
|
# Find matching session
|
||||||
|
if topic_id:
|
||||||
|
target_pattern = f"%topic:{topic_id}%"
|
||||||
|
cur.execute(
|
||||||
|
"SELECT session_key, current_session_id, status, updated_at, entry_json FROM session_nodes WHERE session_key LIKE ? AND session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1",
|
||||||
|
(target_pattern,)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT session_key, current_session_id, status, updated_at, entry_json FROM session_nodes WHERE session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
print(f"[-] No session found in {agent} DB (filter: topic={topic_id})")
|
||||||
|
con.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
skey, sid, status, updated_at_ms, entry_json = row
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
age_min = (now_ms - updated_at_ms) / 60000.0 if updated_at_ms else 0.0
|
||||||
|
|
||||||
|
entry = {}
|
||||||
|
if entry_json:
|
||||||
|
try:
|
||||||
|
entry = json.loads(entry_json)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tokens = entry.get("totalTokens", "?")
|
||||||
|
model = entry.get("model", "default")
|
||||||
|
last_run_id = entry.get("activeWriterRunId") or entry.get("activeRunId", None)
|
||||||
|
|
||||||
|
# Detect anomalies
|
||||||
|
badges = []
|
||||||
|
if isinstance(tokens, int):
|
||||||
|
if tokens > 60000:
|
||||||
|
badges.append("🚨 [CRITICAL_CONTEXT_BLOAT: >60k tokens]")
|
||||||
|
elif tokens > 20000:
|
||||||
|
badges.append("⚠️ [HIGH_CONTEXT_TOKENS: >20k tokens]")
|
||||||
|
if status == "running" and age_min > 5.0:
|
||||||
|
badges.append("🔒 [POSSIBLY_WEDGED_RUNNING: updated >5m ago]")
|
||||||
|
elif status == "processing":
|
||||||
|
badges.append("⚡ [ACTIVE_PROCESSING]")
|
||||||
|
|
||||||
|
# Check ingress queue status
|
||||||
|
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
|
||||||
|
if topic_id and os.path.exists(state_db):
|
||||||
|
try:
|
||||||
|
s_con = sqlite3.connect(state_db, timeout=2.0)
|
||||||
|
lane_pat = f"%topic:{topic_id}%"
|
||||||
|
pending_cnt = s_con.execute(
|
||||||
|
"SELECT count(*) FROM channel_ingress_events WHERE lane_key LIKE ? AND status IN ('pending', 'claimed')",
|
||||||
|
(lane_pat,)
|
||||||
|
).fetchone()[0]
|
||||||
|
s_con.close()
|
||||||
|
if pending_cnt > 0:
|
||||||
|
badges.append(f"📬 [INGRESS_STALLED: {pending_cnt} message(s) pending]")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print(f"🎯 AGENT: {agent.upper():<12} | TOPIC: {topic_id or 'N/A':<6} | STATUS: {status.upper()}")
|
||||||
|
print(f" SESSION KEY: {skey}")
|
||||||
|
print(f" SESSION ID: {sid}")
|
||||||
|
print(f" MODEL: {model}")
|
||||||
|
print(f" TOKENS: {tokens} | LAST UPDATED: {age_min:.1f}m ago")
|
||||||
|
if badges:
|
||||||
|
print(f" ANOMALIES: {' '.join(badges)}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
# Query transcript events
|
||||||
|
cur.execute(
|
||||||
|
"SELECT seq, created_at, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT ?",
|
||||||
|
(sid, limit)
|
||||||
|
)
|
||||||
|
events = cur.fetchall()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
print(" (No transcript events found)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
events.reverse()
|
||||||
|
print("\n📜 RECENT TRANSCRIPT EVENTS (Chronological):")
|
||||||
|
|
||||||
|
last_role = None
|
||||||
|
for seq, ca_ms, ej_str in events:
|
||||||
|
try:
|
||||||
|
ev = json.loads(ej_str)
|
||||||
|
except Exception:
|
||||||
|
print(f"[{seq}] <unparseable event json>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
etype = ev.get("type")
|
||||||
|
if etype != "message":
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = ev.get("message", {})
|
||||||
|
role = msg.get("role")
|
||||||
|
last_role = role
|
||||||
|
content = msg.get("content", [])
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
txt = content if isinstance(content, str) else json.dumps(content)
|
||||||
|
# Check for audio transcript
|
||||||
|
if isinstance(content, str) and "[Audio]" in content:
|
||||||
|
print(f"\n👉 [SEQ {seq}] 🎤 USER AUDIO PROMPT:")
|
||||||
|
for line in content.splitlines()[:6]:
|
||||||
|
print(f" {line}")
|
||||||
|
if len(content.splitlines()) > 6:
|
||||||
|
print(" ...")
|
||||||
|
else:
|
||||||
|
txt_preview = txt.replace("\n", " ")[:240]
|
||||||
|
print(f"\n👉 [SEQ {seq}] 👤 USER: {txt_preview}")
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
print(f"\n🤖 [SEQ {seq}] ASSISTANT TURN:")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for item in content:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
itype = item.get("type")
|
||||||
|
if itype == "thinking":
|
||||||
|
th = item.get("thinking", "").replace("\n", " ")
|
||||||
|
print(f" 🧠 THINKING: {th[:280]}...")
|
||||||
|
elif itype == "toolCall":
|
||||||
|
tname = item.get("name", "unknown")
|
||||||
|
targs = json.dumps(item.get("arguments", {}))
|
||||||
|
print(f" ⚙️ TOOL CALL: {tname}({targs[:160]})")
|
||||||
|
elif itype == "text":
|
||||||
|
tx = item.get("text", "")
|
||||||
|
lines = tx.splitlines()
|
||||||
|
prev = lines[0][:150] if lines else ""
|
||||||
|
print(f" 💬 TEXT OUTPUT: {prev}...")
|
||||||
|
elif isinstance(content, str):
|
||||||
|
print(f" 💬 TEXT OUTPUT: {content[:200]}...")
|
||||||
|
|
||||||
|
elif role == "toolResult":
|
||||||
|
is_err = ev.get("isError", False)
|
||||||
|
tname = ev.get("details", {}).get("tool") or msg.get("toolName", "tool")
|
||||||
|
res_txt = json.dumps(content) if not isinstance(content, str) else content
|
||||||
|
if is_err or "error" in res_txt.lower() or "must have required property" in res_txt:
|
||||||
|
print(f" ❌ [SEQ {seq}] TOOL ERROR ({tname}): {res_txt[:260]}")
|
||||||
|
else:
|
||||||
|
print(f" ✅ [SEQ {seq}] TOOL RESULT ({tname}): {res_txt[:180]}")
|
||||||
|
|
||||||
|
if last_role == "user":
|
||||||
|
print("\n⚠️ WARNING: The last event in the transcript is an UNANSWERED USER MESSAGE!")
|
||||||
|
print(" The agent turn either crashed, timed out, or was killed before producing a response.")
|
||||||
|
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
if not args.topic and not args.agent:
|
||||||
|
print("Usage: openclaw-triage.py (--topic <ID> | --agent <NAME>) [--limit <N>]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
agent = args.agent
|
||||||
|
topic = args.topic
|
||||||
|
|
||||||
|
if topic and not agent:
|
||||||
|
agent = TOPIC_TO_AGENT.get(str(topic))
|
||||||
|
if not agent:
|
||||||
|
# Search all DBs
|
||||||
|
for a, t in TOPIC_TO_AGENT.items():
|
||||||
|
if t == topic:
|
||||||
|
agent = a
|
||||||
|
break
|
||||||
|
|
||||||
|
if not agent:
|
||||||
|
# Scan all DBs for the topic
|
||||||
|
for db in sorted(glob.glob(os.path.join(args.db_root, "*/agent/openclaw-agent.sqlite"))):
|
||||||
|
cand_agent = db.split(os.sep)[-3]
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db, timeout=2.0)
|
||||||
|
cur = con.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT session_key FROM session_nodes WHERE session_key LIKE ? LIMIT 1",
|
||||||
|
(f"%topic:{topic}%",)
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
agent = cand_agent
|
||||||
|
con.close()
|
||||||
|
break
|
||||||
|
con.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not agent:
|
||||||
|
print(f"[-] Could not map topic '{topic}' to any known OpenClaw agent.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success = triage_agent_session(agent, topic_id=topic, limit=args.limit, db_root=args.db_root)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
p = "/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/garmin_mcp/workouts.py"
|
||||||
|
if os.path.exists(p):
|
||||||
|
with open(p, "r") as f:
|
||||||
|
c = f.read()
|
||||||
|
if "_normalize_sport_type" not in c:
|
||||||
|
patch = '''
|
||||||
|
SPORT_TYPE_MAPPINGS = {
|
||||||
|
"running": {"sportTypeId": 1, "sportTypeKey": "running", "displayOrder": 1},
|
||||||
|
"cycling": {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2},
|
||||||
|
"other": {"sportTypeId": 3, "sportTypeKey": "other", "displayOrder": 3},
|
||||||
|
"lap_swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
|
||||||
|
"swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
|
||||||
|
"strength_training": {"sportTypeId": 5, "sportTypeKey": "strength_training", "displayOrder": 5},
|
||||||
|
"cardio_training": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
|
||||||
|
"cardio": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
|
||||||
|
"yoga": {"sportTypeId": 7, "sportTypeKey": "yoga", "displayOrder": 8},
|
||||||
|
"pilates": {"sportTypeId": 8, "sportTypeKey": "pilates", "displayOrder": 9},
|
||||||
|
"hiit": {"sportTypeId": 9, "sportTypeKey": "hiit", "displayOrder": 7},
|
||||||
|
"mobility": {"sportTypeId": 11, "sportTypeKey": "mobility", "displayOrder": 11},
|
||||||
|
"walking": {"sportTypeId": 12, "sportTypeKey": "walking", "displayOrder": 10},
|
||||||
|
"rucking": {"sportTypeId": 13, "sportTypeKey": "rucking", "displayOrder": 12},
|
||||||
|
}
|
||||||
|
SPORT_ID_MAPPINGS = {v["sportTypeId"]: v for v in SPORT_TYPE_MAPPINGS.values()}
|
||||||
|
|
||||||
|
def _resolve_sport_block(st):
|
||||||
|
if isinstance(st, str):
|
||||||
|
k = st.strip().lower()
|
||||||
|
if k in SPORT_TYPE_MAPPINGS:
|
||||||
|
return dict(SPORT_TYPE_MAPPINGS[k])
|
||||||
|
elif isinstance(st, dict):
|
||||||
|
key = st.get("sportTypeKey")
|
||||||
|
sid = st.get("sportTypeId")
|
||||||
|
if key and str(key).strip().lower() in SPORT_TYPE_MAPPINGS:
|
||||||
|
base = dict(SPORT_TYPE_MAPPINGS[str(key).strip().lower()])
|
||||||
|
if sid is not None and sid != 0 and sid != base["sportTypeId"]:
|
||||||
|
raise ValueError(f"sportType mismatch: {key} vs {sid}")
|
||||||
|
return base
|
||||||
|
if sid is not None and sid in SPORT_ID_MAPPINGS:
|
||||||
|
return dict(SPORT_ID_MAPPINGS[sid])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_sport_type(workout_data: dict) -> None:
|
||||||
|
resolved = _resolve_sport_block(workout_data.get("sportType"))
|
||||||
|
segments = workout_data.get("workoutSegments") or []
|
||||||
|
if not resolved and segments:
|
||||||
|
for seg in segments:
|
||||||
|
resolved = _resolve_sport_block(seg.get("sportType"))
|
||||||
|
if resolved:
|
||||||
|
break
|
||||||
|
if not resolved:
|
||||||
|
raise ValueError("Missing or invalid sportType. Both sportTypeId and sportTypeKey are required.")
|
||||||
|
workout_data["sportType"] = resolved
|
||||||
|
for seg in segments:
|
||||||
|
st = _resolve_sport_block(seg.get("sportType"))
|
||||||
|
seg["sportType"] = st if st else dict(resolved)
|
||||||
|
|
||||||
|
'''
|
||||||
|
t = "def _normalize_workout_steps(workout_data: dict) -> None:"
|
||||||
|
if t in c:
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write(c.replace(t, patch + t + "\n _normalize_sport_type(workout_data)", 1))
|
||||||
|
print("Successfully patched garmin_mcp workouts.py!")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { globSync } from "node:fs";
|
||||||
|
|
||||||
|
const baseDir = "/home/node/.openclaw/extensions/perplexity/dist";
|
||||||
|
if (fs.existsSync(baseDir)) {
|
||||||
|
const files = fs.readdirSync(baseDir).filter(f => f.startsWith("perplexity-web-search-provider.runtime-") && f.endsWith(".js"));
|
||||||
|
for (const filename of files) {
|
||||||
|
const file = path.join(baseDir, filename);
|
||||||
|
let code = fs.readFileSync(file, "utf8");
|
||||||
|
if (!code.includes("/* PATCHED_CHAT_COMPLETIONS */")) {
|
||||||
|
const target = "const structured = runtime.transport === \"search_api\";";
|
||||||
|
const fastPath = `const structured = runtime.transport === "search_api"; /* PATCHED_CHAT_COMPLETIONS */
|
||||||
|
if (!structured) {
|
||||||
|
const cacheKey = buildSearchCacheKey([
|
||||||
|
"perplexity",
|
||||||
|
runtime.transport,
|
||||||
|
runtime.baseUrl,
|
||||||
|
runtime.model,
|
||||||
|
query,
|
||||||
|
freshness
|
||||||
|
]);
|
||||||
|
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
||||||
|
const cached = readCachedSearchPayload(cacheKey, cacheTtlMs);
|
||||||
|
if (cached) return cached;
|
||||||
|
const start = Date.now();
|
||||||
|
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||||
|
const result = await runPerplexitySearch({
|
||||||
|
query,
|
||||||
|
apiKey: runtime.apiKey,
|
||||||
|
baseUrl: runtime.baseUrl,
|
||||||
|
model: runtime.model,
|
||||||
|
timeoutSeconds,
|
||||||
|
signal,
|
||||||
|
freshness
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
query,
|
||||||
|
provider: "perplexity",
|
||||||
|
model: runtime.model,
|
||||||
|
tookMs: Date.now() - start,
|
||||||
|
externalContent: {
|
||||||
|
untrusted: true,
|
||||||
|
source: "web_search",
|
||||||
|
provider: "perplexity",
|
||||||
|
wrapped: true
|
||||||
|
},
|
||||||
|
content: wrapWebContent(result.content, "web_search"),
|
||||||
|
citations: result.citations
|
||||||
|
};
|
||||||
|
writeCachedSearchPayload(cacheKey, payload);
|
||||||
|
return payload;
|
||||||
|
}`;
|
||||||
|
if (code.includes(target)) {
|
||||||
|
code = code.replace(target, fastPath);
|
||||||
|
fs.writeFileSync(file, code, "utf8");
|
||||||
|
console.log("Patched perplexity runtime for chat completions:", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import glob, sqlite3, json, os, sys, time
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
recovered = 0
|
||||||
|
interrupted_turns = []
|
||||||
|
|
||||||
|
def extract_prompt(msg):
|
||||||
|
if not msg: return ""
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, str): return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for c in content:
|
||||||
|
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
|
||||||
|
elif isinstance(c, str): parts.append(c)
|
||||||
|
return "\n".join(filter(None, parts))
|
||||||
|
return str(content or "")
|
||||||
|
|
||||||
|
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db, timeout=10.0)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
while True:
|
||||||
|
cursor.execute("PRAGMA foreign_key_check;")
|
||||||
|
violations = cursor.fetchall()
|
||||||
|
if not violations: break
|
||||||
|
for table, rowid, parent, fkid in violations:
|
||||||
|
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
|
||||||
|
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
|
||||||
|
for sk, ej, csid, st in rows:
|
||||||
|
try:
|
||||||
|
p = json.loads(ej) if ej else {}
|
||||||
|
sid = csid or p.get("sessionId")
|
||||||
|
if sid:
|
||||||
|
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
|
||||||
|
if ev:
|
||||||
|
ev_seq, ev_raw = ev
|
||||||
|
try:
|
||||||
|
ev_data = json.loads(ev_raw)
|
||||||
|
msg = ev_data.get("message") or ev_data
|
||||||
|
if st == "running" and msg.get("role") == "user":
|
||||||
|
prompt_text = extract_prompt(msg)
|
||||||
|
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
|
||||||
|
interrupted_turns.append({
|
||||||
|
"agent": agent_id,
|
||||||
|
"session_key": sk,
|
||||||
|
"session_id": sid,
|
||||||
|
"context": ctx,
|
||||||
|
"prompt": prompt_text
|
||||||
|
})
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
|
||||||
|
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
|
||||||
|
p["status"] = "done"
|
||||||
|
p["activeWriterRunId"] = None
|
||||||
|
p["activeClaimRunId"] = None
|
||||||
|
p["restartRecoveryDeliveryRunId"] = None
|
||||||
|
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
|
||||||
|
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
|
||||||
|
recovered += 1
|
||||||
|
except Exception: pass
|
||||||
|
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
|
||||||
|
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
|
||||||
|
if os.path.exists(state_db):
|
||||||
|
try:
|
||||||
|
sconn = sqlite3.connect(state_db, timeout=10.0)
|
||||||
|
scur = sconn.cursor()
|
||||||
|
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
|
||||||
|
sconn.commit()
|
||||||
|
sconn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if interrupted_turns:
|
||||||
|
try:
|
||||||
|
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
|
||||||
|
json.dump(interrupted_turns, f, indent=2)
|
||||||
|
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os, subprocess, sys, time, urllib.request
|
||||||
|
|
||||||
|
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
|
||||||
|
|
||||||
|
if not os.path.exists(INTERRUPTED_FILE):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(INTERRUPTED_FILE, "r") as f:
|
||||||
|
turns = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not turns:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
|
||||||
|
|
||||||
|
gateway_ready = False
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
gateway_ready = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if not gateway_ready:
|
||||||
|
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Allow 5s for Telegram ingress to bind
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
for turn in turns:
|
||||||
|
sk = turn.get("session_key")
|
||||||
|
sid = turn.get("session_id")
|
||||||
|
agent_id = turn.get("agent")
|
||||||
|
ctx = turn.get("context") or {}
|
||||||
|
prompt = turn.get("prompt") or ""
|
||||||
|
|
||||||
|
channel = ctx.get("channel") or "telegram"
|
||||||
|
to_target = ctx.get("to") or ""
|
||||||
|
thread_id = ctx.get("threadId")
|
||||||
|
|
||||||
|
target = to_target
|
||||||
|
if target.startswith("telegram:"):
|
||||||
|
parts = target.split(":")
|
||||||
|
target = parts[1] if len(parts) > 1 else target
|
||||||
|
|
||||||
|
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
|
||||||
|
|
||||||
|
# 1. Send status notification to Telegram if target exists
|
||||||
|
if target:
|
||||||
|
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
|
||||||
|
notify_cmd = [
|
||||||
|
"openclaw", "message", "send",
|
||||||
|
"--channel", channel,
|
||||||
|
"--target", str(target),
|
||||||
|
"-m", notice
|
||||||
|
]
|
||||||
|
if thread_id is not None:
|
||||||
|
notify_cmd.extend(["--thread-id", str(thread_id)])
|
||||||
|
try:
|
||||||
|
subprocess.run(notify_cmd, check=False, timeout=30)
|
||||||
|
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 2. Replay agent turn
|
||||||
|
if sk and prompt:
|
||||||
|
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
|
||||||
|
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
|
||||||
|
agent_cmd = [
|
||||||
|
"openclaw", "agent",
|
||||||
|
"--session-key", sk,
|
||||||
|
"-m", prompt,
|
||||||
|
"--deliver"
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
if res.returncode == 0:
|
||||||
|
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
|
||||||
|
else:
|
||||||
|
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(INTERRUPTED_FILE)
|
||||||
|
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sync_crons.py
|
||||||
|
Idempotently reconciles declarative OpenClaw automations defined in crons.yaml
|
||||||
|
with the running OpenClaw gateway state.
|
||||||
|
|
||||||
|
Can run directly inside the container or remotely via kubectl exec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
venv_py = "/home/node/.openclaw/mcp-venv/bin/python3"
|
||||||
|
if os.path.exists(venv_py) and sys.executable != venv_py:
|
||||||
|
os.execv(venv_py, [venv_py] + sys.argv)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def find_crons_file():
|
||||||
|
candidates = [
|
||||||
|
os.environ.get("CRONS_FILE"),
|
||||||
|
"crons.yaml",
|
||||||
|
"k8s/openclaw/crons.yaml",
|
||||||
|
"/home/node/.openclaw/scripts/crons.yaml",
|
||||||
|
os.path.join(os.path.dirname(__file__), "../crons.yaml"),
|
||||||
|
os.path.join(os.path.dirname(__file__), "crons.yaml"),
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.exists(c):
|
||||||
|
return os.path.abspath(c)
|
||||||
|
raise FileNotFoundError("Could not find crons.yaml. Pass via CRONS_FILE environment variable.")
|
||||||
|
|
||||||
|
def is_inside_container():
|
||||||
|
return os.path.exists("/home/node/.openclaw") or os.environ.get("OPENCLAW_HOME") is not None
|
||||||
|
|
||||||
|
def run_openclaw_cmd(args):
|
||||||
|
if is_inside_container():
|
||||||
|
cmd = ["openclaw"] + args
|
||||||
|
else:
|
||||||
|
namespace = os.environ.get("NAMESPACE", "openclaw")
|
||||||
|
pod = os.environ.get("POD_NAME", "openclaw-0")
|
||||||
|
container = os.environ.get("CONTAINER_NAME", "openclaw")
|
||||||
|
cmd = ["kubectl", "exec", "-n", namespace, pod, "-c", container, "--", "openclaw"] + args
|
||||||
|
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
raise RuntimeError(f"OpenClaw CLI command failed ({' '.join(cmd)}):\n{res.stderr}\n{res.stdout}")
|
||||||
|
return res.stdout
|
||||||
|
|
||||||
|
def list_existing_jobs():
|
||||||
|
raw = run_openclaw_cmd(["cron", "list", "--json"])
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
return data.get("jobs", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: could not parse cron list json: {e}\nRaw output: {raw[:300]}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def reconcile_jobs():
|
||||||
|
file_path = find_crons_file()
|
||||||
|
print(f"=== Syncing OpenClaw Crons-as-Code from {file_path} ===")
|
||||||
|
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
spec = yaml.safe_load(f)
|
||||||
|
|
||||||
|
declarations = spec.get("jobs", [])
|
||||||
|
existing_jobs = list_existing_jobs()
|
||||||
|
|
||||||
|
print(f"Found {len(declarations)} declared jobs in YAML, {len(existing_jobs)} active jobs on gateway.")
|
||||||
|
|
||||||
|
for job in declarations:
|
||||||
|
name = job.get("name")
|
||||||
|
decl_key = job.get("declarationKey") or name
|
||||||
|
desc = job.get("description", "")
|
||||||
|
sched = job.get("schedule", {})
|
||||||
|
|
||||||
|
# Match existing job by declarationKey or exact name
|
||||||
|
match = next((j for j in existing_jobs if j.get("declarationKey") == decl_key or j.get("name") == name), None)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
existing_id = match.get("id")
|
||||||
|
print(f"Removing existing job '{name}' (ID {existing_id}) for idempotent recreation...")
|
||||||
|
try:
|
||||||
|
run_openclaw_cmd(["cron", "rm", existing_id])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning removing {existing_id}: {e}")
|
||||||
|
|
||||||
|
# Build add command
|
||||||
|
cmd = [
|
||||||
|
"cron", "add",
|
||||||
|
"--name", name,
|
||||||
|
"--declaration-key", decl_key,
|
||||||
|
"--description", desc,
|
||||||
|
]
|
||||||
|
|
||||||
|
if sched.get("kind") == "every":
|
||||||
|
cmd.extend(["--every", sched.get("every", "15m")])
|
||||||
|
elif sched.get("kind") == "cron":
|
||||||
|
cmd.extend(["--cron", sched.get("expr", "* * * * *")])
|
||||||
|
if sched.get("tz"):
|
||||||
|
cmd.extend(["--tz", sched.get("tz")])
|
||||||
|
|
||||||
|
if job.get("session"):
|
||||||
|
cmd.extend(["--session", job.get("session")])
|
||||||
|
|
||||||
|
if job.get("delivery") == "none":
|
||||||
|
cmd.append("--no-deliver")
|
||||||
|
|
||||||
|
if job.get("command"):
|
||||||
|
cmd.extend(["--command", job.get("command")])
|
||||||
|
|
||||||
|
if job.get("agent"):
|
||||||
|
cmd.extend(["--agent", job.get("agent")])
|
||||||
|
|
||||||
|
if job.get("model"):
|
||||||
|
cmd.extend(["--model", job.get("model")])
|
||||||
|
|
||||||
|
if job.get("channel"):
|
||||||
|
cmd.extend(["--channel", job.get("channel")])
|
||||||
|
|
||||||
|
if job.get("to"):
|
||||||
|
cmd.extend(["--to", job.get("to")])
|
||||||
|
|
||||||
|
if job.get("threadId"):
|
||||||
|
cmd.extend(["--thread-id", str(job.get("threadId"))])
|
||||||
|
|
||||||
|
if job.get("announce"):
|
||||||
|
cmd.append("--announce")
|
||||||
|
|
||||||
|
if job.get("message"):
|
||||||
|
cmd.extend(["--message", job.get("message")])
|
||||||
|
|
||||||
|
print(f"Registering job '{name}'...")
|
||||||
|
run_openclaw_cmd(cmd)
|
||||||
|
print(f"Successfully registered '{name}'.")
|
||||||
|
|
||||||
|
print("=== OpenClaw Crons-as-Code reconciliation complete! ===")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
reconcile_jobs()
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# setup-compaction-cron.sh
|
||||||
|
# Declaratively registers OpenClaw session maintenance automations:
|
||||||
|
# 1. Tracking Sliding Window (every 15m): Truncates mila_tracker & nutrition_tracker
|
||||||
|
# to <= 8 events (~5 messages / 2 turns) via instant native gateway truncation.
|
||||||
|
# 2. Nightly Deep Compaction (every night at 04:45 Europe/Berlin):
|
||||||
|
# - Pre-warms SQLite page cache on rotational storage
|
||||||
|
# - Recovers wedged/quarantined sessions
|
||||||
|
# - Prunes abandoned isolated cron sessions (>48h)
|
||||||
|
# - Deep-compacts conversational topics (koch, hebamme, shopping)
|
||||||
|
# - Smart-compacts active long-running sessions (itguy, recherche, etc.)
|
||||||
|
# - Flushes SQLite WAL journals and executes PRAGMA optimize
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAMESPACE="${NAMESPACE:-openclaw}"
|
||||||
|
POD_NAME="${POD_NAME:-openclaw-0}"
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== Registering Multi-Agent Session Maintenance in OpenClaw ($POD_NAME) ==="
|
||||||
|
|
||||||
|
# 1. Install maintenance scripts from repo onto PVC
|
||||||
|
echo "Syncing compact_tracking_sessions.py to pod..."
|
||||||
|
kubectl exec -i -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- /bin/sh -c \
|
||||||
|
'cat > /home/node/.openclaw/scripts/compact_tracking_sessions.py && chmod +x /home/node/.openclaw/scripts/compact_tracking_sessions.py' \
|
||||||
|
< "$SCRIPT_DIR/scripts/compact_tracking_sessions.py"
|
||||||
|
|
||||||
|
echo "Syncing compact_sessions.py to pod..."
|
||||||
|
kubectl exec -i -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- /bin/sh -c \
|
||||||
|
'cat > /home/node/.openclaw/scripts/compact_sessions.py && chmod +x /home/node/.openclaw/scripts/compact_sessions.py' \
|
||||||
|
< "$SCRIPT_DIR/scripts/compact_sessions.py"
|
||||||
|
|
||||||
|
# 2. Register / Update 15-Minute Tracking Sliding Window Automation
|
||||||
|
TRACKING_JOB="tracking-session-sliding-window"
|
||||||
|
echo "Checking existing OpenClaw Cron automation '$TRACKING_JOB'..."
|
||||||
|
EXISTING_TRACKING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$TRACKING_JOB"'"), ""))' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_TRACKING_ID" ]; then
|
||||||
|
echo "Updating existing tracking sliding window automation '$EXISTING_TRACKING_ID' (every 15m)..."
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron edit "$EXISTING_TRACKING_ID" \
|
||||||
|
--every "15m" \
|
||||||
|
--command "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
|
||||||
|
else
|
||||||
|
echo "Registering new tracking sliding window automation '$TRACKING_JOB' (every 15m)..."
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||||
|
--name "Tracking Session Sliding Window" \
|
||||||
|
--declaration-key "$TRACKING_JOB" \
|
||||||
|
--description "Lightweight sliding-window truncation (max 8 events) for mila_tracker and nutrition_tracker" \
|
||||||
|
--every "15m" \
|
||||||
|
--session "isolated" \
|
||||||
|
--no-deliver \
|
||||||
|
--command "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Register / Update Nightly Deep Compaction Automation
|
||||||
|
NIGHTLY_JOB="nightly-session-compaction"
|
||||||
|
CRON_SCHEDULE="45 4 * * *"
|
||||||
|
TIMEZONE="Europe/Berlin"
|
||||||
|
|
||||||
|
echo "Checking existing OpenClaw Cron automation '$NIGHTLY_JOB'..."
|
||||||
|
EXISTING_NIGHTLY_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$NIGHTLY_JOB"'"), ""))' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_NIGHTLY_ID" ]; then
|
||||||
|
echo "Updating existing nightly compaction automation '$EXISTING_NIGHTLY_ID' ($CRON_SCHEDULE @ $TIMEZONE)..."
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron edit "$EXISTING_NIGHTLY_ID" \
|
||||||
|
--cron "$CRON_SCHEDULE" \
|
||||||
|
--tz "$TIMEZONE" \
|
||||||
|
--command "python3 /home/node/.openclaw/scripts/compact_sessions.py"
|
||||||
|
else
|
||||||
|
echo "Registering new nightly compaction automation '$NIGHTLY_JOB' ($CRON_SCHEDULE @ $TIMEZONE)..."
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||||
|
--name "Nightly Session Compaction" \
|
||||||
|
--declaration-key "$NIGHTLY_JOB" \
|
||||||
|
--description "Nightly multi-agent session compaction and SQLite WAL checkpointing" \
|
||||||
|
--cron "$CRON_SCHEDULE" \
|
||||||
|
--tz "$TIMEZONE" \
|
||||||
|
--session "isolated" \
|
||||||
|
--no-deliver \
|
||||||
|
--command "python3 /home/node/.openclaw/scripts/compact_sessions.py"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== All session maintenance automations successfully registered/updated! ==="
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# setup-dashboards-cron.sh
|
||||||
|
# Declaratively registers periodic background publishing for Mila & Moritz dashboards in OpenClaw.
|
||||||
|
# Schedule: Every 10 minutes (pure shell command execution, zero LLM token cost)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAMESPACE="${NAMESPACE:-openclaw}"
|
||||||
|
POD_NAME="${POD_NAME:-openclaw-0}"
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
|
||||||
|
|
||||||
|
JOB_NAME="dashboards-periodic-publish"
|
||||||
|
SCHEDULE_EVERY="10m"
|
||||||
|
|
||||||
|
echo "=== Registering Dashboards Periodic Publish in OpenClaw ($POD_NAME) ==="
|
||||||
|
|
||||||
|
# 1. Check if job already exists
|
||||||
|
EXISTING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$JOB_NAME"'" or j.get("name") == "'"$JOB_NAME"'"), ""))' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Found existing job with ID $EXISTING_ID. Removing for idempotent recreation..."
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron rm "$EXISTING_ID" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Add the job as direct shell command execution with --no-deliver
|
||||||
|
echo "Creating automation $JOB_NAME (every $SCHEDULE_EVERY)..."
|
||||||
|
|
||||||
|
CMD="sh -lc 'cd /home/node/mop-skills/mop-skills/skills/datastore && uv run scripts/update_website.py --data-dir /home/node/.openclaw/wiki/main/data && uv run scripts/update_moritz_website.py --data-dir /home/node/.openclaw/wiki/main/data'"
|
||||||
|
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||||
|
--name "$JOB_NAME" \
|
||||||
|
--description "Periodically regenerates and uploads Mila & Moritz family HTML dashboards to S3" \
|
||||||
|
--command "$CMD" \
|
||||||
|
--every "$SCHEDULE_EVERY" \
|
||||||
|
--no-deliver
|
||||||
|
|
||||||
|
echo "=== Automation successfully registered! ==="
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list | grep "$JOB_NAME" || true
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# setup-garmin-cron.sh
|
||||||
|
# Declaratively registers the Garmin Connect Nightly Sync in OpenClaw.
|
||||||
|
# Schedule: Daily at 03:30 Europe/Berlin (cron: 30 3 * * *)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAMESPACE="${NAMESPACE:-openclaw}"
|
||||||
|
POD_NAME="${POD_NAME:-openclaw-0}"
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
|
||||||
|
|
||||||
|
JOB_NAME="garmin-nightly-mirror"
|
||||||
|
CRON_SCHEDULE="30 3 * * *"
|
||||||
|
TIMEZONE="Europe/Berlin"
|
||||||
|
TELEGRAM_GROUP="-1004326921510"
|
||||||
|
TELEGRAM_TOPIC="20"
|
||||||
|
|
||||||
|
echo "=== Registering Garmin Connect Nightly Sync in OpenClaw ($POD_NAME) ==="
|
||||||
|
|
||||||
|
# 1. Check if job already exists
|
||||||
|
EXISTING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$JOB_NAME"'" or j.get("name") == "'"$JOB_NAME"'"), ""))' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Found existing job with ID $EXISTING_ID. Removing for idempotent recreation..."
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron rm "$EXISTING_ID" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Add the job
|
||||||
|
echo "Creating automation $JOB_NAME ($CRON_SCHEDULE @ $TIMEZONE)..."
|
||||||
|
|
||||||
|
MESSAGE=$(cat << 'EOF'
|
||||||
|
Führe den nächtlichen Garmin Connect Sync durch.
|
||||||
|
Vorgehen:
|
||||||
|
1. Führe den Garmin-Sync-Befehl aus:
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 --data-dir /home/node/.openclaw/wiki/main/data
|
||||||
|
2. Finale Antwort:
|
||||||
|
- Bei Erfolg: Gib exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam in der Nacht).
|
||||||
|
- Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow.
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||||
|
--name "$JOB_NAME" \
|
||||||
|
--declaration-key "$JOB_NAME" \
|
||||||
|
--description "Nächtlicher deterministischer Spiegel der Garmin Connect Daten (Aktivitäten, Daily Summary, Schlaf) in Git JSONL" \
|
||||||
|
--cron "$CRON_SCHEDULE" \
|
||||||
|
--tz "$TIMEZONE" \
|
||||||
|
--agent "fitness" \
|
||||||
|
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
|
||||||
|
--session "isolated" \
|
||||||
|
--channel "telegram" \
|
||||||
|
--to "$TELEGRAM_GROUP" \
|
||||||
|
--thread-id "$TELEGRAM_TOPIC" \
|
||||||
|
--announce \
|
||||||
|
--message "$MESSAGE"
|
||||||
|
|
||||||
|
echo "=== Automation successfully registered! ==="
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list | grep "$JOB_NAME" || true
|
||||||
@@ -48,7 +48,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
|
|||||||
--cron "$CRON_SCHEDULE" \
|
--cron "$CRON_SCHEDULE" \
|
||||||
--tz "$TIMEZONE" \
|
--tz "$TIMEZONE" \
|
||||||
--agent "itguy" \
|
--agent "itguy" \
|
||||||
--model "openrouter/deepseek/deepseek-v4.1-flash" \
|
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
|
||||||
--session "isolated" \
|
--session "isolated" \
|
||||||
--channel "telegram" \
|
--channel "telegram" \
|
||||||
--to "$TELEGRAM_GROUP" \
|
--to "$TELEGRAM_GROUP" \
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: sasch40
|
||||||
|
labels:
|
||||||
|
app: sasch40
|
||||||
|
namespace: web
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: sasch40
|
||||||
|
replicas: 1
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: sasch40
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: "registry-haumdaucher-de"
|
||||||
|
containers:
|
||||||
|
- image: registry.haumdaucher.de/sasch40:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
name: sasch40
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 20m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 64Mi
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: sasch40
|
||||||
|
namespace: web
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
protocol: TCP
|
||||||
|
selector:
|
||||||
|
app: sasch40
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: sasch40
|
||||||
|
namespace: web
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- "sasch40.haumdaucher.de"
|
||||||
|
secretName: sasch40-haumdaucher-de-tls
|
||||||
|
rules:
|
||||||
|
- host: sasch40.haumdaucher.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: sasch40
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
+1
-1
Submodule knowledge-wiki updated: 85cb53ffbb...71ee240511
@@ -0,0 +1,13 @@
|
|||||||
|
# AGENTS.md — DuckDB Backend
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
FastAPI HTTP backend executing in-memory DuckDB queries against Git-backed JSONL data in the Knowledge Wiki.
|
||||||
|
|
||||||
|
## Development & Testing
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
uv run --project projects/duckdb-backend pytest -v projects/duckdb-backend/tests
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
uv run --project projects/duckdb-backend uvicorn app.main:app --port 8000
|
||||||
|
```
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Install system dependencies (git for wiki sync)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv for fast dependency resolution
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN uv pip install --system --no-cache -r pyproject.toml
|
||||||
|
|
||||||
|
# Copy application source
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PORT=8000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# DuckDB Backend
|
||||||
|
|
||||||
|
HTTP-based analytical and transactional backend for personal logging data stored in the Knowledge Wiki.
|
||||||
|
Powered by Python 3.12, FastAPI, and in-memory DuckDB querying JSONL files directly.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Single Source of Truth**: Knowledge Wiki (`/wiki` git repository with data in `/wiki/data/**/*.jsonl`).
|
||||||
|
- **Engine**: In-memory DuckDB reading `sleep`, `nutrition`, `mila`, `entities`, and `garmin` partitions.
|
||||||
|
- **Git Synchronization**:
|
||||||
|
- Background periodic `git pull --rebase origin main` (every 60s).
|
||||||
|
- Mutating operations (`POST /duckdb-api/v1/events`) append to typed JSONL partition files, sort them, commit, rebase, and push to origin main.
|
||||||
|
- Conflicts trigger automatic rebase abort and flag `git_conflict: true` on `/status` and `/healthz`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /duckdb-api/v1/mila/dashboard`: Curated aggregated dashboard for Mila (sleep, wake timer, feedings, diapers, WHO percentiles).
|
||||||
|
- `GET /duckdb-api/v1/moritz/dashboard`: Curated aggregated dashboard for Moritz (today's intake, yesterday's 24h deficit/surplus, Garmin recovery & sleep, 7d/30d trends).
|
||||||
|
- `POST /duckdb-api/v1/query`: Generic read-only SQL queries against DuckDB tables and views.
|
||||||
|
- `POST /duckdb-api/v1/events`: Record new events (sleep, nutrition, diaper, feeding, growth).
|
||||||
|
- `POST /duckdb-api/v1/events/stop`: Close active open sleep session or interval.
|
||||||
|
- `DELETE /duckdb-api/v1/events/{id}`: Soft-delete event.
|
||||||
|
- `POST /duckdb-api/v1/entities`: Upsert entities (`foods`, `recipes`, `places`, `people`).
|
||||||
|
- `GET /duckdb-api/v1/healthz`: Liveness and readiness probe.
|
||||||
|
- `GET /duckdb-api/v1/status`: Git synchronization status and table row counts.
|
||||||
|
- `POST /duckdb-api/v1/git/sync`: Manual pull or webhook sync trigger.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# app package
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def find_default_wiki_dir() -> Path:
|
||||||
|
env_wiki = os.environ.get("WIKI_DIR") or os.environ.get("GIT_REPO_DIR")
|
||||||
|
if env_wiki:
|
||||||
|
return Path(env_wiki).resolve()
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
Path("/wiki"),
|
||||||
|
Path.cwd() / "knowledge-wiki",
|
||||||
|
Path.cwd().parent.parent / "knowledge-wiki",
|
||||||
|
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
|
||||||
|
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki",
|
||||||
|
Path("/home/node/.openclaw/wiki/main"),
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_dir() and (c / "data").is_dir():
|
||||||
|
return c.resolve()
|
||||||
|
|
||||||
|
return (Path.cwd() / "knowledge-wiki").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
WIKI_DIR = find_default_wiki_dir()
|
||||||
|
DATA_DIR = Path(
|
||||||
|
os.environ.get("DATA_DIR")
|
||||||
|
or os.environ.get("DUCKDB_DATA_DIR")
|
||||||
|
or (WIKI_DIR / "data")
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
GIT_REPO_URL = os.environ.get(
|
||||||
|
"GIT_REPO_URL",
|
||||||
|
os.environ.get("GIT_REMOTE_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git"),
|
||||||
|
)
|
||||||
|
GIT_USERNAME = os.environ.get("GIT_SYNC_USERNAME", "")
|
||||||
|
GIT_PASSWORD = os.environ.get("GIT_SYNC_PASSWORD", os.environ.get("WIKI_SYNC_TOKEN", ""))
|
||||||
|
GIT_BRANCH = os.environ.get("GIT_BRANCH", "main")
|
||||||
|
GIT_SYNC_INTERVAL_SECONDS = int(os.environ.get("GIT_SYNC_INTERVAL_SECONDS", "60"))
|
||||||
|
AUTO_GIT_SYNC = os.environ.get("AUTO_GIT_SYNC", "true").lower() in ("1", "true", "yes")
|
||||||
|
GIT_AUTHOR_NAME = os.environ.get("GIT_AUTHOR_NAME", "DuckDB Backend")
|
||||||
|
GIT_AUTHOR_EMAIL = os.environ.get("GIT_AUTHOR_EMAIL", "duckdb-backend@haumdaucher.de")
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("PORT", "8000"))
|
||||||
|
HOST = os.environ.get("HOST", "0.0.0.0")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
AUTO_GIT_SYNC,
|
||||||
|
GIT_AUTHOR_EMAIL,
|
||||||
|
GIT_AUTHOR_NAME,
|
||||||
|
GIT_BRANCH,
|
||||||
|
GIT_PASSWORD,
|
||||||
|
GIT_REPO_URL,
|
||||||
|
GIT_SYNC_INTERVAL_SECONDS,
|
||||||
|
GIT_USERNAME,
|
||||||
|
WIKI_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("duckdb_backend.git_sync")
|
||||||
|
|
||||||
|
|
||||||
|
class GitSyncManager:
|
||||||
|
def __init__(self, wiki_dir: Optional[Path] = None, on_data_pulled: Optional[Callable[[], None]] = None):
|
||||||
|
self.wiki_dir = wiki_dir or WIKI_DIR
|
||||||
|
self.on_data_pulled = on_data_pulled
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.status = "ok"
|
||||||
|
self.is_conflict = False
|
||||||
|
self.last_sync_time: Optional[str] = None
|
||||||
|
self.last_commit_hash: Optional[str] = None
|
||||||
|
self.last_error: Optional[str] = None
|
||||||
|
self._bg_task: Optional[asyncio.Task] = None
|
||||||
|
self._stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
def _configure_git_client(self):
|
||||||
|
"""Configures git client with safe.directory and author identity."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["git", "config", "--global", "http.sslVerify", "false"], check=False)
|
||||||
|
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=False)
|
||||||
|
if (self.wiki_dir / ".git").exists():
|
||||||
|
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.name", GIT_AUTHOR_NAME], check=False)
|
||||||
|
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.email", GIT_AUTHOR_EMAIL], check=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to configure git client options: {e}")
|
||||||
|
|
||||||
|
def get_auth_repo_url(self) -> str:
|
||||||
|
if GIT_USERNAME and GIT_PASSWORD and "://" in GIT_REPO_URL:
|
||||||
|
proto, rest = GIT_REPO_URL.split("://", 1)
|
||||||
|
# Remove existing credentials in URL if any
|
||||||
|
if "@" in rest:
|
||||||
|
rest = rest.split("@", 1)[1]
|
||||||
|
user_enc = urllib.parse.quote(GIT_USERNAME, safe="")
|
||||||
|
pass_enc = urllib.parse.quote(GIT_PASSWORD, safe="")
|
||||||
|
return f"{proto}://{user_enc}:{pass_enc}@{rest}"
|
||||||
|
return GIT_REPO_URL
|
||||||
|
|
||||||
|
def ensure_repo(self) -> bool:
|
||||||
|
"""Ensures repository exists and is initialized."""
|
||||||
|
with self._lock:
|
||||||
|
self._configure_git_client()
|
||||||
|
if (self.wiki_dir / ".git").exists():
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
self.last_commit_hash = res.stdout.strip()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to check git rev: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not GIT_REPO_URL:
|
||||||
|
logger.info(f"Wiki directory {self.wiki_dir} is not a git repo, but no GIT_REPO_URL configured.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Initializing repository {GIT_REPO_URL} in {self.wiki_dir}...")
|
||||||
|
self.wiki_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
auth_url = self.get_auth_repo_url()
|
||||||
|
try:
|
||||||
|
# Use git init + remote + fetch + checkout to safely support non-empty directories (e.g. lost+found on PVC)
|
||||||
|
subprocess.run(["git", "-C", str(self.wiki_dir), "init"], capture_output=True, check=False)
|
||||||
|
self._configure_git_client()
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "remote", "add", "origin", auth_url],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "fetch", "origin", GIT_BRANCH],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
if (self.wiki_dir / ".git" / "shallow").exists():
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "fetch", "--unshallow", "origin", GIT_BRANCH],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
checkout_res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "checkout", "-f", "-B", GIT_BRANCH, f"origin/{GIT_BRANCH}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if checkout_res.returncode == 0:
|
||||||
|
logger.info("Successfully initialized and checked out wiki repository.")
|
||||||
|
self.status = "ok"
|
||||||
|
self.is_conflict = False
|
||||||
|
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.status = "error"
|
||||||
|
self.last_error = f"Checkout failed: {checkout_res.stderr.strip()}"
|
||||||
|
logger.error(self.last_error)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.status = "error"
|
||||||
|
self.last_error = f"Fetch failed: {res.stderr.strip()}"
|
||||||
|
logger.error(self.last_error)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "error"
|
||||||
|
self.last_error = f"Clone exception: {e}"
|
||||||
|
logger.error(self.last_error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def pull_rebase(self) -> bool:
|
||||||
|
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
|
||||||
|
if not (self.wiki_dir / ".git").exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
# Update remote url with credentials if provided
|
||||||
|
auth_url = self.get_auth_repo_url()
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch and rebase with autostash
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if res.returncode == 0:
|
||||||
|
self.status = "ok"
|
||||||
|
self.is_conflict = False
|
||||||
|
self.last_error = None
|
||||||
|
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||||
|
# Update commit hash
|
||||||
|
rev = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if rev.returncode == 0:
|
||||||
|
self.last_commit_hash = rev.stdout.strip()
|
||||||
|
|
||||||
|
if self.on_data_pulled:
|
||||||
|
try:
|
||||||
|
self.on_data_pulled()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error in on_data_pulled callback: {ex}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Check if conflict occurred
|
||||||
|
stderr = res.stderr or res.stdout
|
||||||
|
logger.warning(f"Git pull --rebase failed: {stderr}")
|
||||||
|
# Abort any in-progress rebase to keep working tree stable
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.is_conflict = True
|
||||||
|
self.status = "conflict"
|
||||||
|
self.last_error = f"Rebase conflict: {stderr.strip()}"
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "error"
|
||||||
|
self.last_error = f"Pull error: {e}"
|
||||||
|
logger.error(self.last_error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def commit_and_push(self, commit_msg: str) -> bool:
|
||||||
|
"""Stages data/, commits with descriptive message, rebases and pushes."""
|
||||||
|
if not (self.wiki_dir / ".git").exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self._configure_git_client()
|
||||||
|
data_rel = "data"
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "add", data_rel],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
commit_res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "commit", "-m", f"{commit_msg} [skip ci]"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if commit_res.returncode != 0:
|
||||||
|
# Nothing to commit or already committed
|
||||||
|
logger.debug(f"Commit returncode {commit_res.returncode}: {commit_res.stdout.strip()}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Pull rebase with autostash before push
|
||||||
|
auth_url = self.get_auth_repo_url()
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
rebase_res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if rebase_res.returncode != 0:
|
||||||
|
stderr = rebase_res.stderr or rebase_res.stdout
|
||||||
|
logger.warning(f"Rebase failed during commit_and_push: {stderr}")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.is_conflict = True
|
||||||
|
self.status = "conflict"
|
||||||
|
self.last_error = f"Rebase conflict on push: {stderr.strip()}"
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Push to remote
|
||||||
|
push_res = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "push", "origin", GIT_BRANCH],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if push_res.returncode == 0:
|
||||||
|
self.status = "ok"
|
||||||
|
self.is_conflict = False
|
||||||
|
self.last_error = None
|
||||||
|
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||||
|
rev = subprocess.run(
|
||||||
|
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if rev.returncode == 0:
|
||||||
|
self.last_commit_hash = rev.stdout.strip()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.status = "error"
|
||||||
|
self.last_error = f"Push error: {push_res.stderr.strip()}"
|
||||||
|
logger.error(self.last_error)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "error"
|
||||||
|
self.last_error = f"Commit/push error: {e}"
|
||||||
|
logger.error(self.last_error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def start_periodic_sync(self):
|
||||||
|
"""Starts background periodic git pull loop."""
|
||||||
|
if not AUTO_GIT_SYNC:
|
||||||
|
logger.info("Automatic git sync is disabled by configuration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
|
async def _loop():
|
||||||
|
logger.info(f"Starting periodic git sync every {GIT_SYNC_INTERVAL_SECONDS}s")
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(self.pull_rebase)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error during periodic git pull: {ex}")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._stop_event.wait(), timeout=GIT_SYNC_INTERVAL_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._bg_task = asyncio.create_task(_loop())
|
||||||
|
|
||||||
|
async def stop_periodic_sync(self):
|
||||||
|
"""Stops background sync loop."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._bg_task:
|
||||||
|
self._bg_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._bg_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._bg_task = None
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import DATA_DIR, WIKI_DIR
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.git_sync import GitSyncManager
|
||||||
|
from app.routes.dashboard import router as dashboard_router
|
||||||
|
from app.routes.entities import router as entities_router
|
||||||
|
from app.routes.events import router as events_router
|
||||||
|
from app.routes.query import router as query_router
|
||||||
|
from app.routes.system import router as system_router
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
|
logger = logging.getLogger("duckdb_backend")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Initializing DuckDB Backend...")
|
||||||
|
if getattr(app.state, "git_sync", None) is None or getattr(app.state, "engine", None) is None:
|
||||||
|
git_sync = GitSyncManager(
|
||||||
|
wiki_dir=WIKI_DIR,
|
||||||
|
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
|
||||||
|
)
|
||||||
|
git_sync.ensure_repo()
|
||||||
|
app.state.git_sync = git_sync
|
||||||
|
|
||||||
|
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
|
||||||
|
app.state.engine = engine
|
||||||
|
|
||||||
|
await git_sync.start_periodic_sync()
|
||||||
|
logger.info("DuckDB Backend is ready to serve queries.")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
logger.info("Shutting down DuckDB Backend...")
|
||||||
|
await git_sync.stop_periodic_sync()
|
||||||
|
else:
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="DuckDB Backend for Knowledge Wiki",
|
||||||
|
version="1.0.0",
|
||||||
|
description="HTTP-based analytical and transactional backend for personal logging data powered by DuckDB",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# API v1 prefix
|
||||||
|
v1_router = FastAPI()
|
||||||
|
app.include_router(dashboard_router, prefix="/duckdb-api/v1")
|
||||||
|
app.include_router(query_router, prefix="/duckdb-api/v1")
|
||||||
|
app.include_router(events_router, prefix="/duckdb-api/v1")
|
||||||
|
app.include_router(entities_router, prefix="/duckdb-api/v1")
|
||||||
|
app.include_router(system_router, prefix="/duckdb-api/v1")
|
||||||
|
|
||||||
|
# Direct root health probes for Kubernetes
|
||||||
|
app.include_router(system_router)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
class QueryRequest(BaseModel):
|
||||||
|
query: str = Field(..., description="Read-only SQL query to execute in DuckDB")
|
||||||
|
params: Optional[List[Any]] = Field(default=None, description="Positional query parameters")
|
||||||
|
|
||||||
|
class QueryResponse(BaseModel):
|
||||||
|
columns: List[str]
|
||||||
|
rows: List[Dict[str, Any]]
|
||||||
|
count: int
|
||||||
|
execution_time_ms: float
|
||||||
|
|
||||||
|
class EventCreateRequest(BaseModel):
|
||||||
|
kind: str = Field(..., description="Event kind, e.g. sleep.session, nutrition.intake, diaper.change, feeding.bottle, growth.measurement")
|
||||||
|
subject: str = Field(..., description="Event subject, e.g. mila or moritz")
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict, description="Event-specific payload")
|
||||||
|
actor: Optional[str] = Field(default="web-app", description="Actor who logged the event")
|
||||||
|
date: Optional[str] = Field(default=None, description="Explicit log date override YYYY-MM-DD")
|
||||||
|
start: Optional[str] = Field(default=None, description="ISO-8601 start timestamp")
|
||||||
|
end: Optional[str] = Field(default=None, description="ISO-8601 end timestamp (optional)")
|
||||||
|
since: Optional[str] = Field(default=None, description="Relative start duration like '15m', '1h'")
|
||||||
|
estimated: bool = Field(default=False, description="Whether intake is estimated")
|
||||||
|
meal: Optional[str] = Field(default=None, description="Meal category, e.g. fruehstueck, mittagessen, abendessen, snack")
|
||||||
|
note: Optional[str] = Field(default=None, description="Free-text note")
|
||||||
|
source_ref: Optional[str] = Field(default=None, description="Source reference")
|
||||||
|
allow_duplicate: bool = Field(default=False, description="Allow event within 15min of duplicate")
|
||||||
|
|
||||||
|
class EventStopRequest(BaseModel):
|
||||||
|
kind: str = Field(default="sleep.session", description="Event kind to stop")
|
||||||
|
subject: str = Field(..., description="Event subject, e.g. mila")
|
||||||
|
end_time: Optional[str] = Field(default=None, description="ISO-8601 end timestamp")
|
||||||
|
since: Optional[str] = Field(default=None, description="Relative end duration like '5m'")
|
||||||
|
event_id: Optional[str] = Field(default=None, description="Specific event ID to close")
|
||||||
|
actor: Optional[str] = Field(default="web-app", description="Actor who stopped the event")
|
||||||
|
|
||||||
|
class EntityPutRequest(BaseModel):
|
||||||
|
collection: str = Field(..., description="Target collection: foods, recipes, places, people")
|
||||||
|
record: Dict[str, Any] = Field(..., description="Entity record with 'id'")
|
||||||
|
|
||||||
|
class GarminSyncRequest(BaseModel):
|
||||||
|
daily: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin daily summary records")
|
||||||
|
sleep: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin sleep records")
|
||||||
|
activities: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin activity records")
|
||||||
|
daily_summary: Optional[Dict[str, Any]] = None
|
||||||
|
sleep_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_aliases(cls, data: Any) -> Any:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Normalize daily_summary dict to daily list if daily is missing/empty
|
||||||
|
if "daily_summary" in data and data["daily_summary"] and not data.get("daily"):
|
||||||
|
data["daily"] = [data["daily_summary"]]
|
||||||
|
# Normalize sleep_data dict to sleep list if sleep is missing/empty
|
||||||
|
if "sleep_data" in data and data["sleep_data"] and not data.get("sleep"):
|
||||||
|
data["sleep"] = [data["sleep_data"]]
|
||||||
|
return data
|
||||||
|
|
||||||
|
class StatusResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
git_conflict: bool
|
||||||
|
last_sync_time: Optional[str]
|
||||||
|
last_commit_hash: Optional[str]
|
||||||
|
last_error: Optional[str]
|
||||||
|
data_dir: str
|
||||||
|
table_counts: Dict[str, int]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# routes package
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Dashboards"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(request: Request) -> DuckDBEngine:
|
||||||
|
return request.app.state.engine
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mila/dashboard")
|
||||||
|
async def get_mila_dashboard(
|
||||||
|
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.get_mila_dashboard(as_of_date=as_of)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/moritz/dashboard")
|
||||||
|
async def get_moritz_dashboard(
|
||||||
|
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.get_moritz_dashboard(as_of_date=as_of)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mila/day/{date}")
|
||||||
|
async def get_mila_day_detail(
|
||||||
|
date: str,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.get_mila_day_detail(target_date=date)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/moritz/day/{date}")
|
||||||
|
async def get_moritz_day_detail(
|
||||||
|
date: str,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.get_moritz_day_detail(target_date=date)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary/{subject}")
|
||||||
|
async def get_summary(
|
||||||
|
subject: str,
|
||||||
|
date: Optional[str] = Query(default=None, description="Target date YYYY-MM-DD"),
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.get_summary(subject=subject, date_str=date)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.models import EntityPutRequest
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Entities"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(request: Request) -> DuckDBEngine:
|
||||||
|
return request.app.state.engine
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entities")
|
||||||
|
async def put_entity(
|
||||||
|
req: EntityPutRequest,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.put_entity(req)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entities/{collection}")
|
||||||
|
async def list_entities(
|
||||||
|
collection: str,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
return engine.list_entities(collection=collection)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/places/near")
|
||||||
|
async def find_places_near(
|
||||||
|
lat: float = Query(..., description="Latitude"),
|
||||||
|
lon: float = Query(..., description="Longitude"),
|
||||||
|
radius_m: float = Query(default=1000.0, description="Radius in meters"),
|
||||||
|
category: Optional[str] = Query(default=None, description="Category filter"),
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
return engine.find_places_near(lat=lat, lon=lon, radius_m=radius_m, category=category)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.models import EventCreateRequest, EventStopRequest, GarminSyncRequest
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Events"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(request: Request) -> DuckDBEngine:
|
||||||
|
return request.app.state.engine
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events")
|
||||||
|
async def add_event(
|
||||||
|
req: EventCreateRequest,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.add_event(req)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/stop")
|
||||||
|
async def stop_event(
|
||||||
|
req: EventStopRequest,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.stop_event(req)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events/{event_id}")
|
||||||
|
async def delete_event(
|
||||||
|
event_id: str,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.delete_event(event_id)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=404, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/garmin/sync")
|
||||||
|
async def sync_garmin(
|
||||||
|
req: GarminSyncRequest,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return engine.sync_garmin(req)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.models import QueryRequest, QueryResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Generic Queries"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(request: Request) -> DuckDBEngine:
|
||||||
|
return request.app.state.engine
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/query", response_model=QueryResponse)
|
||||||
|
async def execute_query(
|
||||||
|
req: QueryRequest,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> QueryResponse:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
rows = engine.query(req.query, req.params)
|
||||||
|
duration_ms = round((time.perf_counter() - t0) * 1000, 2)
|
||||||
|
cols = list(rows[0].keys()) if rows else []
|
||||||
|
return QueryResponse(
|
||||||
|
columns=cols,
|
||||||
|
rows=rows,
|
||||||
|
count=len(rows),
|
||||||
|
execution_time_ms=duration_ms,
|
||||||
|
)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from app.config import DATA_DIR
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.git_sync import GitSyncManager
|
||||||
|
from app.models import StatusResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["System"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(request: Request) -> DuckDBEngine:
|
||||||
|
return request.app.state.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_sync(request: Request) -> Optional[GitSyncManager]:
|
||||||
|
return getattr(request.app.state, "git_sync", None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthz")
|
||||||
|
async def healthz(
|
||||||
|
request: Request,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
git_sync: Optional[GitSyncManager] = get_git_sync(request)
|
||||||
|
conflict = git_sync.is_conflict if git_sync else False
|
||||||
|
status_str = "conflict" if conflict else "ok"
|
||||||
|
return {
|
||||||
|
"status": status_str,
|
||||||
|
"conflict": conflict,
|
||||||
|
"engine_ready": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=StatusResponse)
|
||||||
|
async def status(
|
||||||
|
request: Request,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> StatusResponse:
|
||||||
|
git_sync: Optional[GitSyncManager] = get_git_sync(request)
|
||||||
|
counts = engine.get_table_counts()
|
||||||
|
return StatusResponse(
|
||||||
|
status=git_sync.status if git_sync else "ok",
|
||||||
|
git_conflict=git_sync.is_conflict if git_sync else False,
|
||||||
|
last_sync_time=git_sync.last_sync_time if git_sync else None,
|
||||||
|
last_commit_hash=git_sync.last_commit_hash if git_sync else None,
|
||||||
|
last_error=git_sync.last_error if git_sync else None,
|
||||||
|
data_dir=str(engine.data_dir),
|
||||||
|
table_counts=counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/git/sync")
|
||||||
|
async def trigger_sync(
|
||||||
|
request: Request,
|
||||||
|
engine: DuckDBEngine = Depends(get_engine),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
git_sync: Optional[GitSyncManager] = get_git_sync(request)
|
||||||
|
if not git_sync:
|
||||||
|
raise HTTPException(status_code=400, detail="Git sync is not configured")
|
||||||
|
|
||||||
|
success = git_sync.pull_rebase()
|
||||||
|
if success:
|
||||||
|
engine.reload_all()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Git pull --rebase completed and DuckDB reloaded.",
|
||||||
|
"commit": git_sync.last_commit_hash,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": git_sync.status,
|
||||||
|
"conflict": git_sync.is_conflict,
|
||||||
|
"error": git_sync.last_error,
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
WHO_GIRLS_WEIGHT = [
|
||||||
|
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
|
||||||
|
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
|
||||||
|
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
|
||||||
|
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
|
||||||
|
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
|
||||||
|
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
|
||||||
|
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
|
||||||
|
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
|
||||||
|
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
|
||||||
|
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
|
||||||
|
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
|
||||||
|
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
|
||||||
|
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
|
||||||
|
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
|
||||||
|
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
|
||||||
|
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
|
||||||
|
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
|
||||||
|
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
|
||||||
|
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
|
||||||
|
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
|
||||||
|
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
|
||||||
|
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
|
||||||
|
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
|
||||||
|
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
|
||||||
|
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
|
||||||
|
]
|
||||||
|
|
||||||
|
WHO_GIRLS_HEIGHT = [
|
||||||
|
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
|
||||||
|
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
|
||||||
|
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
|
||||||
|
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
|
||||||
|
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
|
||||||
|
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
|
||||||
|
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
|
||||||
|
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
|
||||||
|
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
|
||||||
|
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
|
||||||
|
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
|
||||||
|
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
|
||||||
|
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
|
||||||
|
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
|
||||||
|
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
|
||||||
|
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
|
||||||
|
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
|
||||||
|
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
|
||||||
|
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
|
||||||
|
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
|
||||||
|
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
|
||||||
|
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
|
||||||
|
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
|
||||||
|
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
|
||||||
|
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
|
||||||
|
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
|
||||||
|
m_low = int(math.floor(m_clamped))
|
||||||
|
m_high = int(math.ceil(m_clamped))
|
||||||
|
|
||||||
|
r_low = table[m_low]
|
||||||
|
if m_low == m_high:
|
||||||
|
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
|
||||||
|
|
||||||
|
r_high = table[m_high]
|
||||||
|
frac = m_clamped - m_low
|
||||||
|
|
||||||
|
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
|
||||||
|
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
|
||||||
|
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
|
||||||
|
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
|
||||||
|
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
|
||||||
|
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
|
||||||
|
|
||||||
|
return L, M, S, p3, p50, p97
|
||||||
|
|
||||||
|
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
|
||||||
|
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
|
||||||
|
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
|
||||||
|
|
||||||
|
if value <= 0 or M <= 0 or S <= 0:
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"age_months": round(age_months, 2),
|
||||||
|
"z_score": 0.0,
|
||||||
|
"percentile": 50.0,
|
||||||
|
"percentile_str": "P50",
|
||||||
|
"p3": round(p3, 2),
|
||||||
|
"p50": round(p50, 2),
|
||||||
|
"p97": round(p97, 2),
|
||||||
|
"in_normal_range": True,
|
||||||
|
"status": "normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
if abs(L) > 1e-6:
|
||||||
|
z = ((value / M) ** L - 1.0) / (L * S)
|
||||||
|
else:
|
||||||
|
z = math.log(value / M) / S
|
||||||
|
|
||||||
|
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
|
||||||
|
p_clamped = max(0.1, min(99.9, p))
|
||||||
|
|
||||||
|
in_normal = 3.0 <= p_clamped <= 97.0
|
||||||
|
if p_clamped < 3.0:
|
||||||
|
status = "unter P3"
|
||||||
|
elif p_clamped > 97.0:
|
||||||
|
status = "über P97"
|
||||||
|
else:
|
||||||
|
status = "im WHO-Normbereich"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"age_months": round(age_months, 2),
|
||||||
|
"z_score": round(z, 2),
|
||||||
|
"percentile": round(p_clamped, 1),
|
||||||
|
"percentile_str": f"P{int(round(p_clamped))}",
|
||||||
|
"p3": round(p3, 2),
|
||||||
|
"p50": round(p50, 2),
|
||||||
|
"p97": round(p97, 2),
|
||||||
|
"in_normal_range": in_normal,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[project]
|
||||||
|
name = "duckdb-backend"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "FastAPI HTTP DuckDB Backend for Knowledge Wiki Personal Data"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"duckdb>=1.1.0",
|
||||||
|
"python-ulid>=3.0.0",
|
||||||
|
"pytz>=2024.1",
|
||||||
|
"pydantic>=2.8.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import DATA_DIR
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.git_sync import GitSyncManager
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_data_dir():
|
||||||
|
temp_root = Path(tempfile.mkdtemp())
|
||||||
|
temp_data = temp_root / "data"
|
||||||
|
if DATA_DIR.is_dir():
|
||||||
|
shutil.copytree(DATA_DIR, temp_data)
|
||||||
|
else:
|
||||||
|
temp_data.mkdir(parents=True, exist_ok=True)
|
||||||
|
yield temp_data
|
||||||
|
shutil.rmtree(temp_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine(test_data_dir):
|
||||||
|
return DuckDBEngine(data_dir=test_data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(engine):
|
||||||
|
app.state.engine = engine
|
||||||
|
app.state.git_sync = GitSyncManager(wiki_dir=engine.data_dir.parent)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_mila_dashboard_endpoint(client: TestClient):
|
||||||
|
resp = client.get("/duckdb-api/v1/mila/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["metadata"]["subject"] == "mila"
|
||||||
|
assert "live_status" in data
|
||||||
|
assert "today" in data
|
||||||
|
assert "growth" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_moritz_dashboard_endpoint(client: TestClient):
|
||||||
|
resp = client.get("/duckdb-api/v1/moritz/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["metadata"]["subject"] == "moritz"
|
||||||
|
assert "today" in data
|
||||||
|
assert "yesterday" in data
|
||||||
|
assert "seven_days" in data
|
||||||
|
assert "thirty_days" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz_and_status_endpoints(client: TestClient):
|
||||||
|
resp_h = client.get("/healthz")
|
||||||
|
assert resp_h.status_code == 200
|
||||||
|
assert resp_h.json()["engine_ready"] is True
|
||||||
|
|
||||||
|
resp_s = client.get("/duckdb-api/v1/status")
|
||||||
|
assert resp_s.status_code == 200
|
||||||
|
data_s = resp_s.json()
|
||||||
|
assert "status" in data_s
|
||||||
|
assert "table_counts" in data_s
|
||||||
|
assert data_s["table_counts"]["sleep"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mila_history_includes_diaper_only_days(client: TestClient):
|
||||||
|
# Log a diaper change for a specific day without sleep
|
||||||
|
client.post("/duckdb-api/v1/events", json={
|
||||||
|
"kind": "diaper.change",
|
||||||
|
"subject": "mila",
|
||||||
|
"start": "2026-10-12T12:00:00+02:00",
|
||||||
|
"payload": {"pee": True, "poo": True},
|
||||||
|
})
|
||||||
|
resp = client.get("/duckdb-api/v1/mila/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
history = data["history"]
|
||||||
|
dates = [h["date"] for h in history]
|
||||||
|
assert "2026-10-12" in dates
|
||||||
|
|
||||||
|
|
||||||
|
def test_moritz_seven_days_includes_garmin_days(client: TestClient):
|
||||||
|
resp = client.get("/duckdb-api/v1/moritz/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
days = data["seven_days"]["days"]
|
||||||
|
assert len(days) > 0
|
||||||
|
# Every returned day has a valid date and steps or expenditure
|
||||||
|
for d in days:
|
||||||
|
assert d["date"] is not None
|
||||||
|
assert "expenditure_kcal" in d
|
||||||
|
|
||||||
|
|
||||||
|
def test_mila_dashboard_parent_cockpit_and_norms(client: TestClient):
|
||||||
|
resp = client.get("/duckdb-api/v1/mila/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Parent cockpit live fields
|
||||||
|
live = data["live_status"]
|
||||||
|
assert "is_sleeping" in live
|
||||||
|
assert "active_sleep_duration_str" in live
|
||||||
|
assert "wake_window_minutes" in live
|
||||||
|
assert "last_feed_since_str" in live
|
||||||
|
assert "last_diaper_since_str" in live
|
||||||
|
assert "last_stool_str" in live
|
||||||
|
|
||||||
|
# Science age norms
|
||||||
|
assert "age_norms" in data
|
||||||
|
norms = data["age_norms"]
|
||||||
|
assert norms["age_months"] >= 6.0
|
||||||
|
assert norms["wake_window_min_minutes"] == 135
|
||||||
|
assert norms["wake_window_max_minutes"] == 180
|
||||||
|
assert norms["feed_interval_min_minutes"] == 180
|
||||||
|
|
||||||
|
# Ensure IDs on items
|
||||||
|
today = data["today"]
|
||||||
|
for f in today["feedings"]:
|
||||||
|
assert "id" in f
|
||||||
|
for d in today["diapers"]:
|
||||||
|
assert "id" in d
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_detail_and_summary_endpoints(client: TestClient):
|
||||||
|
# Test mila day detail
|
||||||
|
resp_mila = client.get("/duckdb-api/v1/mila/day/2026-10-12")
|
||||||
|
assert resp_mila.status_code == 200
|
||||||
|
assert resp_mila.json()["metadata"]["today_date"] == "2026-10-12"
|
||||||
|
|
||||||
|
# Test moritz day detail
|
||||||
|
resp_moritz = client.get("/duckdb-api/v1/moritz/day/2026-10-12")
|
||||||
|
assert resp_moritz.status_code == 200
|
||||||
|
assert "today" in resp_moritz.json()
|
||||||
|
|
||||||
|
# Test summary
|
||||||
|
resp_sum = client.get("/duckdb-api/v1/summary/mila?date=2026-10-12")
|
||||||
|
assert resp_sum.status_code == 200
|
||||||
|
sum_data = resp_sum.json()
|
||||||
|
assert sum_data["subject"] == "mila"
|
||||||
|
assert sum_data["date"] == "2026-10-12"
|
||||||
|
assert "nutrition" in sum_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_entities_and_places_endpoints(client: TestClient):
|
||||||
|
# Put and list entity
|
||||||
|
client.post("/duckdb-api/v1/entities", json={
|
||||||
|
"collection": "places",
|
||||||
|
"record": {
|
||||||
|
"id": "test-biergarten",
|
||||||
|
"name": "Test Biergarten",
|
||||||
|
"category": "biergarten",
|
||||||
|
"lat": 49.02,
|
||||||
|
"lon": 12.09,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resp_places = client.get("/duckdb-api/v1/entities/places")
|
||||||
|
assert resp_places.status_code == 200
|
||||||
|
places = resp_places.json()
|
||||||
|
assert any(p["id"] == "test-biergarten" for p in places)
|
||||||
|
|
||||||
|
# Places near search
|
||||||
|
resp_near = client.get("/duckdb-api/v1/places/near?lat=49.02&lon=12.09&radius_m=500")
|
||||||
|
assert resp_near.status_code == 200
|
||||||
|
near = resp_near.json()
|
||||||
|
assert len(near) > 0
|
||||||
|
assert near[0]["id"] == "test-biergarten"
|
||||||
|
|
||||||
|
|
||||||
|
def test_garmin_sync_and_duplicate_guard(client: TestClient):
|
||||||
|
# Duplicate guard test
|
||||||
|
post_data = {
|
||||||
|
"kind": "feeding.bottle",
|
||||||
|
"subject": "mila",
|
||||||
|
"start": "2026-10-12T10:00:00+02:00",
|
||||||
|
"payload": {"consumed_ml": 180, "milk_type": "pre"},
|
||||||
|
}
|
||||||
|
r1 = client.post("/duckdb-api/v1/events", json=post_data)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["status"] == "success"
|
||||||
|
assert "feedback" in r1.json()
|
||||||
|
|
||||||
|
# Second post within 15min should be duplicate_skipped
|
||||||
|
r2 = client.post("/duckdb-api/v1/events", json=post_data)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["status"] == "duplicate_skipped"
|
||||||
|
|
||||||
|
# Garmin sync test
|
||||||
|
garmin_payload = {
|
||||||
|
"daily": [{
|
||||||
|
"calendarDate": "2026-10-15",
|
||||||
|
"totalKilocalories": 2400,
|
||||||
|
"activeKilocalories": 600,
|
||||||
|
"bmrKilocalories": 1800,
|
||||||
|
"totalSteps": 11000,
|
||||||
|
"restingHeartRate": 52,
|
||||||
|
}],
|
||||||
|
"sleep": [],
|
||||||
|
"activities": [],
|
||||||
|
}
|
||||||
|
r_sync = client.post("/duckdb-api/v1/garmin/sync", json=garmin_payload)
|
||||||
|
assert r_sync.status_code == 200
|
||||||
|
assert r_sync.json()["status"] == "success"
|
||||||
|
assert r_sync.json()["synced_daily"] == 1
|
||||||
|
|
||||||
|
# Test alias support (daily_summary and sleep_data dicts)
|
||||||
|
garmin_alias_payload = {
|
||||||
|
"date": "2026-10-16",
|
||||||
|
"daily_summary": {
|
||||||
|
"calendarDate": "2026-10-16",
|
||||||
|
"totalKilocalories": 2550,
|
||||||
|
"activeKilocalories": 650,
|
||||||
|
"bmrKilocalories": 1900,
|
||||||
|
"totalSteps": 12000,
|
||||||
|
"restingHeartRate": 50,
|
||||||
|
},
|
||||||
|
"sleep_data": {
|
||||||
|
"dailySleepDTO": {
|
||||||
|
"calendarDate": "2026-10-16",
|
||||||
|
"sleepTimeSeconds": 28800,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activities": [],
|
||||||
|
}
|
||||||
|
r_alias = client.post("/duckdb-api/v1/garmin/sync", json=garmin_alias_payload)
|
||||||
|
assert r_alias.status_code == 200
|
||||||
|
assert r_alias.json()["status"] == "success"
|
||||||
|
assert r_alias.json()["synced_daily"] == 1
|
||||||
|
assert r_alias.json()["synced_sleep"] == 1
|
||||||
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
from app.models import EventCreateRequest, EventStopRequest
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_stop_sleep_event(tmp_path: Path):
|
||||||
|
# Use isolated temp directory for write tests so we do not pollute wiki git repo
|
||||||
|
engine = DuckDBEngine(data_dir=tmp_path)
|
||||||
|
|
||||||
|
# 1. Add active sleep event (end=None)
|
||||||
|
add_req = EventCreateRequest(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
payload={"type": "nap", "location": "kinderwagen"},
|
||||||
|
actor="test-runner",
|
||||||
|
note="Mittagsschlaf Test",
|
||||||
|
)
|
||||||
|
res_add = engine.add_event(add_req)
|
||||||
|
assert res_add["status"] == "success"
|
||||||
|
event_id = res_add["id"]
|
||||||
|
assert event_id is not None
|
||||||
|
|
||||||
|
# Check that it appears in open sleep
|
||||||
|
open_rows = engine.query(f"SELECT * FROM sleep WHERE id = '{event_id}' AND (isOpen = true OR endIso IS NULL)")
|
||||||
|
assert len(open_rows) == 1
|
||||||
|
|
||||||
|
# 2. Stop the active sleep event
|
||||||
|
stop_req = EventStopRequest(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
event_id=event_id,
|
||||||
|
actor="test-runner",
|
||||||
|
)
|
||||||
|
res_stop = engine.stop_event(stop_req)
|
||||||
|
assert res_stop["status"] == "success"
|
||||||
|
assert res_stop["id"] == event_id
|
||||||
|
|
||||||
|
# Check that it is now closed
|
||||||
|
closed_rows = engine.query(f"SELECT isOpen, endIso FROM sleep WHERE id = '{event_id}'")
|
||||||
|
assert len(closed_rows) == 1
|
||||||
|
assert closed_rows[0]["isOpen"] is False
|
||||||
|
assert closed_rows[0]["endIso"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_nutrition_and_diaper_event(tmp_path: Path):
|
||||||
|
engine = DuckDBEngine(data_dir=tmp_path)
|
||||||
|
|
||||||
|
# Add nutrition event
|
||||||
|
nut_req = EventCreateRequest(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
payload={"item": "Apfel", "qty": 150.0, "kcal": 78.0, "protein_g": 0.5},
|
||||||
|
meal="snack",
|
||||||
|
actor="test-runner",
|
||||||
|
)
|
||||||
|
res_nut = engine.add_event(nut_req)
|
||||||
|
assert res_nut["status"] == "success"
|
||||||
|
nut_id = res_nut["id"]
|
||||||
|
|
||||||
|
nut_rows = engine.query(f"SELECT * FROM nutrition WHERE id = '{nut_id}'")
|
||||||
|
assert len(nut_rows) == 1
|
||||||
|
|
||||||
|
# Add diaper event
|
||||||
|
diaper_req = EventCreateRequest(
|
||||||
|
kind="diaper.change",
|
||||||
|
subject="mila",
|
||||||
|
payload={"pee": True, "poo": False},
|
||||||
|
actor="test-runner",
|
||||||
|
)
|
||||||
|
res_diaper = engine.add_event(diaper_req)
|
||||||
|
assert res_diaper["status"] == "success"
|
||||||
|
diaper_id = res_diaper["id"]
|
||||||
|
|
||||||
|
diaper_rows = engine.query(f"SELECT * FROM mila WHERE id = '{diaper_id}'")
|
||||||
|
assert len(diaper_rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_open_sleep_rejected(tmp_path: Path):
|
||||||
|
engine = DuckDBEngine(data_dir=tmp_path)
|
||||||
|
|
||||||
|
# 1. Start first open sleep session
|
||||||
|
req1 = EventCreateRequest(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
actor="parent",
|
||||||
|
)
|
||||||
|
res1 = engine.add_event(req1)
|
||||||
|
assert res1["status"] == "success"
|
||||||
|
|
||||||
|
# 2. Attempt to start second open sleep session without stopping the first
|
||||||
|
req2 = EventCreateRequest(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
payload={"type": "nap", "location": "kinderwagen"},
|
||||||
|
actor="parent",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="already active for subject 'mila'"):
|
||||||
|
engine.add_event(req2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_open_sleep_api_endpoint(client: TestClient):
|
||||||
|
# 1. Start first session via API
|
||||||
|
res1 = client.post("/duckdb-api/v1/events", json={
|
||||||
|
"kind": "sleep.session",
|
||||||
|
"subject": "test_baby",
|
||||||
|
"payload": {"type": "nap"},
|
||||||
|
})
|
||||||
|
assert res1.status_code == 200
|
||||||
|
|
||||||
|
# 2. Start second session via API -> must return 400 Bad Request
|
||||||
|
res2 = client.post("/duckdb-api/v1/events", json={
|
||||||
|
"kind": "sleep.session",
|
||||||
|
"subject": "test_baby",
|
||||||
|
"payload": {"type": "nap"},
|
||||||
|
})
|
||||||
|
assert res2.status_code == 400
|
||||||
|
assert "already active for subject 'test_baby'" in res2.json()["detail"]
|
||||||
|
|
||||||
|
# 3. Clean up by stopping
|
||||||
|
stop_res = client.post("/duckdb-api/v1/events/stop", json={
|
||||||
|
"kind": "sleep.session",
|
||||||
|
"subject": "test_baby",
|
||||||
|
})
|
||||||
|
assert stop_res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_utc_wallclock_guard_and_mila_recipe_defaults(tmp_path: Path):
|
||||||
|
engine = DuckDBEngine(data_dir=tmp_path)
|
||||||
|
|
||||||
|
# 1. Agent passes +00:00 on local wall-clock times (e.g. 08:30 to 10:20)
|
||||||
|
nap_req = EventCreateRequest(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:mila_tracker",
|
||||||
|
start="2026-10-17T08:30:00+00:00",
|
||||||
|
end="2026-10-17T10:20:00+00:00",
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
)
|
||||||
|
res_nap = engine.add_event(nap_req)
|
||||||
|
assert res_nap["status"] == "success"
|
||||||
|
assert res_nap["record"]["startIso"] == "2026-10-17T08:30:00+02:00"
|
||||||
|
assert res_nap["record"]["endIso"] == "2026-10-17T10:20:00+02:00"
|
||||||
|
assert res_nap["record"]["sleepTimeSeconds"] == 6600
|
||||||
|
|
||||||
|
# 2. Solid food without amount_g gets standard recipe defaults
|
||||||
|
brei_req = EventCreateRequest(
|
||||||
|
kind="feeding.solid",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:mila_tracker",
|
||||||
|
start="2026-10-17T12:20:00+00:00",
|
||||||
|
meal="lunch",
|
||||||
|
payload={"item": "mittagsbrei"},
|
||||||
|
)
|
||||||
|
res_brei = engine.add_event(brei_req)
|
||||||
|
assert res_brei["record"]["start"] == "2026-10-17T12:20:00+02:00"
|
||||||
|
assert res_brei["record"]["payload"]["amount_g"] == 190
|
||||||
|
assert res_brei["record"]["payload"]["kcal"] == 135
|
||||||
|
|
||||||
|
abend_req = EventCreateRequest(
|
||||||
|
kind="feeding.solid",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:mila_tracker",
|
||||||
|
start="2026-10-17T16:40:00+00:00",
|
||||||
|
meal="dinner",
|
||||||
|
payload={"item": "abendbrei"},
|
||||||
|
)
|
||||||
|
res_abend = engine.add_event(abend_req)
|
||||||
|
assert res_abend["record"]["start"] == "2026-10-17T16:40:00+02:00"
|
||||||
|
assert res_abend["record"]["payload"]["amount_g"] == 200
|
||||||
|
assert res_abend["record"]["payload"]["kcal"] == 110
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_query_select(client: TestClient):
|
||||||
|
payload = {
|
||||||
|
"query": "SELECT count(*) AS cnt FROM v_sleep WHERE subject = 'mila'"
|
||||||
|
}
|
||||||
|
resp = client.post("/duckdb-api/v1/query", json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "rows" in data
|
||||||
|
assert len(data["rows"]) == 1
|
||||||
|
assert data["rows"][0]["cnt"] > 0
|
||||||
|
assert data["execution_time_ms"] < 50.0 # verifies sub-50ms execution
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_query_disallowed_drop(client: TestClient):
|
||||||
|
payload = {"query": "DROP TABLE sleep;"}
|
||||||
|
resp = client.post("/duckdb-api/v1/query", json=payload)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "not permitted" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_query_disallowed_insert(client: TestClient):
|
||||||
|
payload = {"query": "INSERT INTO sleep VALUES ('x', 'y')"}
|
||||||
|
resp = client.post("/duckdb-api/v1/query", json=payload)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import pytest
|
||||||
|
from app.engine import DuckDBEngine
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_init_and_counts(engine: DuckDBEngine):
|
||||||
|
counts = engine.get_table_counts()
|
||||||
|
assert "sleep" in counts
|
||||||
|
assert "nutrition" in counts
|
||||||
|
assert "mila" in counts
|
||||||
|
assert "garmin_daily" in counts
|
||||||
|
assert counts["sleep"] > 0
|
||||||
|
assert counts["nutrition"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mila_dashboard_payload(engine: DuckDBEngine):
|
||||||
|
data = engine.get_mila_dashboard()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["metadata"]["subject"] == "mila"
|
||||||
|
assert "live_status" in data
|
||||||
|
assert "today" in data
|
||||||
|
assert "averages" in data
|
||||||
|
assert "history" in data
|
||||||
|
assert "growth" in data
|
||||||
|
assert len(data["history"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_moritz_dashboard_payload(engine: DuckDBEngine):
|
||||||
|
data = engine.get_moritz_dashboard()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["metadata"]["subject"] == "moritz"
|
||||||
|
assert "today" in data
|
||||||
|
assert "yesterday" in data
|
||||||
|
assert "garmin_recovery" in data
|
||||||
|
assert "seven_days" in data
|
||||||
|
assert "thirty_days" in data
|
||||||
|
assert len(data["thirty_days"]["days"]) > 0
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from app.git_sync import GitSyncManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_sync_conflict_handling(tmp_path: Path):
|
||||||
|
repo_dir = tmp_path / "fake_repo"
|
||||||
|
repo_dir.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-b", "main", str(repo_dir)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_dir), "config", "user.email", "test@test.local"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_dir), "config", "user.name", "Test User"], check=True)
|
||||||
|
|
||||||
|
test_file = repo_dir / "test.txt"
|
||||||
|
test_file.write_text("initial")
|
||||||
|
subprocess.run(["git", "-C", str(repo_dir), "add", "."], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo_dir), "commit", "-m", "initial commit"], check=True)
|
||||||
|
|
||||||
|
mgr = GitSyncManager(wiki_dir=repo_dir)
|
||||||
|
assert mgr.ensure_repo() is True
|
||||||
|
assert mgr.status == "ok"
|
||||||
|
assert mgr.is_conflict is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_url_encoding(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.git_sync.GIT_USERNAME", "moritz@graf")
|
||||||
|
monkeypatch.setattr("app.git_sync.GIT_PASSWORD", "p@ss:w/ord%123")
|
||||||
|
monkeypatch.setattr("app.git_sync.GIT_REPO_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git")
|
||||||
|
|
||||||
|
mgr = GitSyncManager()
|
||||||
|
auth_url = mgr.get_auth_repo_url()
|
||||||
|
assert auth_url == "https://moritz%40graf:p%40ss%3Aw%2Ford%25123@git.moritzgraf.de/moritz/knowledge-wiki.git"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_repo_handles_non_empty_dir(tmp_path: Path):
|
||||||
|
# Simulate a PVC containing lost+found before repo initialization
|
||||||
|
wiki_pvc = tmp_path / "wiki_pvc"
|
||||||
|
wiki_pvc.mkdir()
|
||||||
|
(wiki_pvc / "lost+found").mkdir()
|
||||||
|
|
||||||
|
# Create upstream origin to fetch from
|
||||||
|
origin = tmp_path / "origin.git"
|
||||||
|
subprocess.run(["git", "init", "--bare", str(origin)], check=True)
|
||||||
|
|
||||||
|
# Push initial commit to bare origin
|
||||||
|
work = tmp_path / "work"
|
||||||
|
work.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-b", "main", str(work)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.name", "Admin"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "config", "user.email", "admin@test.local"], check=True)
|
||||||
|
(work / "README.md").write_text("# Knowledge Wiki")
|
||||||
|
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "commit", "-m", "init"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(origin)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)
|
||||||
|
|
||||||
|
# Now verify GitSyncManager initializes cleanly into non-empty directory
|
||||||
|
mgr = GitSyncManager(wiki_dir=wiki_pvc)
|
||||||
|
# Point URL to file repo
|
||||||
|
import app.git_sync
|
||||||
|
app.git_sync.GIT_REPO_URL = str(origin)
|
||||||
|
app.git_sync.GIT_USERNAME = ""
|
||||||
|
app.git_sync.GIT_PASSWORD = ""
|
||||||
|
|
||||||
|
success = mgr.ensure_repo()
|
||||||
|
assert success is True
|
||||||
|
assert (wiki_pvc / ".git").is_dir()
|
||||||
|
assert (wiki_pvc / "README.md").is_file()
|
||||||
|
assert (wiki_pvc / "lost+found").is_dir()
|
||||||
Generated
+813
@@ -0,0 +1,813 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "duckdb"
|
||||||
|
version = "1.5.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/59/0b/d65ea3be00ea79aa276a8388bec588a9cbf409ce637c6d306e5316210d15/duckdb-1.5.6.tar.gz", hash = "sha256:166a91dbfacfc0c9f08cc76c0243cb6d3d4296bfab5bad72a3cfb63140a5b7c8", size = 18032957, upload-time = "2026-09-28T13:38:37.978Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/d5/d0ab77a0a1702a43171c93874f44c1f6481e30038bd3987df0d77a16a5c6/duckdb-1.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48d07d0651aaeac2c3974afd37599970154b7b79b54c18f27c319c14ccf98d9d", size = 32810486, upload-time = "2026-09-28T13:37:47.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cd/b22201de5377faa3be6c38d5f3eaa504cb480392a448bed6a4d2239469b4/duckdb-1.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79de3dfa8705b1ba0d59e7e3252e40ff399e0afd12f485502a6c7bf7c2fd809a", size = 17405278, upload-time = "2026-09-28T13:37:50.135Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/6d/f9cfb1493bbdc2f095693a402e42dce1192077f9e11573f00baed6a748de/duckdb-1.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcccce20965e6986cd083fdf192c461685ad0b93cd1ccd0b2a8207f1185f078b", size = 15532943, upload-time = "2026-09-28T13:37:52.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/04/f65ccfaa5a833f2e570c4a140f03c8f95da416da9fe8ed08401f81f8242a/duckdb-1.5.6-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce89a1025a5317ebe9c520876c48032b5247ac574865486648b1a004f6009875", size = 19454940, upload-time = "2026-09-28T13:37:55.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/99/be75c788a492f8d77b7a1cdc1b19939ae7be0007f2028691ad371a1a33ee/duckdb-1.5.6-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc9619ed7d4ffa117b5155d84b44794366bb6635178d78ed5e13a6024845c757", size = 21568087, upload-time = "2026-09-28T13:37:58.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/95/889f8508960e47c0a7c75cc5bf57cde8512fc24f8db7b3129cca5388da42/duckdb-1.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:09ff51b230219f0d8b47fc8a1e17fb595ba9fab0c3d96a6de4d00b8ff86b3cf1", size = 13190189, upload-time = "2026-09-28T13:38:00.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/c9/baab503364a68309f8368c88e77f5341e7d94927bdf3e6d703f0e5035f3e/duckdb-1.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:b8d795c8b2d5634b3269f974aa97f1fdf878f62f032317a52252a151b693fb1e", size = 14021977, upload-time = "2026-09-28T13:38:02.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/a476197fcba557738a588ec844747a19bc0a24b0e6f1809e308f29d68c0e/duckdb-1.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ae352646374cacf48e9981cf031191c494865192fc436d13667a2531fc5d1da3", size = 32810376, upload-time = "2026-09-28T13:38:05.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/6d/5466a2b53ddd557644dfa47a763f68748efccdf282e6ae7c4f1bcfb3da69/duckdb-1.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a1261e90785e9d29953293e44f60fa073bd1137098924e8de21a037a861b051", size = 17405385, upload-time = "2026-09-28T13:38:07.363Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/a0/bf87071170835ee4a34fe764fc11c1c6e7040a0e021b36c1b6f834a4c22f/duckdb-1.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97dd7a555b8f5298b76bc7d48a11cb2c64336e8de9bfde783cffb86ea9f54807", size = 15533132, upload-time = "2026-09-28T13:38:09.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/e0/38095c8e140ecfbe847519ac07bcba94301b8fbb76b2870015e33e07f179/duckdb-1.5.6-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364992ba1089a2b327391cfcb68fd0bd0ce9090cf293baef861a0ba6847abfee", size = 19454994, upload-time = "2026-09-28T13:38:11.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/21/61dd2876bbaa69cf77d7b5c620e52e8b25faae7096f4d2e4a812b52095d7/duckdb-1.5.6-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:644f54ce99b3b61844bc9a3fe80e0aecb1ea4084b1fffc4396d1569db6111679", size = 21568700, upload-time = "2026-09-28T13:38:14.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/4a/100730e7785e85268be4d4d5bd62cfc8314e261d2f42efa208243eef35cb/duckdb-1.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:ced693d33ddcee2e5345f077d342c87d2aaa80e41c514e64c9ff2d4e5963c251", size = 13190707, upload-time = "2026-09-28T13:38:16.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/2e/bc7f44eab4e89ee5c1cb427bb1168ad021d985042e6841ec0694c3d3d501/duckdb-1.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:41ecc75bb9328d72d154a705c1a653d2c5c60f686a5c0c6578aa80020753c884", size = 14020962, upload-time = "2026-09-28T13:38:19.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/62/a8a30a4c6b94c0861d348ed5633b963f6745a5525527530f02f3c1a7c931/duckdb-1.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa21d2ad803b2524326e8622d7d96b2bb1ff1d5b60368e1978ee805df9c21fb3", size = 32828003, upload-time = "2026-09-28T13:38:21.414Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/b7/1dcca0005eb8c67adf9fc06bf0cbb1d2bf4ea1974cc89e7a7c2ad66aac28/duckdb-1.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a1b2ad27d414068cbca06c55cfa802eece10f86ea4812ff082f8ab4cb25fc85", size = 17413912, upload-time = "2026-09-28T13:38:23.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/b0/e3ac175443550f3464f2d95731a8b0aae9b4dc3875c3a186c352262b43c2/duckdb-1.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c79c6d222b1d015cde73b5139087186b00db65357fb4e2c94c2308fbbf465a72", size = 15543122, upload-time = "2026-09-28T13:38:26.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/08/cc510a7952aba69d5cdca17f3ef61c95713d86143f2ee9aa3e097d38f50b/duckdb-1.5.6-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1052b8050ef5696e2c0d8c836949c72f3dd11f0690466acbea739613e8e2750b", size = 19457946, upload-time = "2026-09-28T13:38:28.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/a5/6f8099d9a5a02ddff89e5c85875df3465054845b0920fb0703fbdf8dd2ec/duckdb-1.5.6-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c5e485e59613b8878d1670bcaa7a010f53c5a4da5ae8e08863e5e529ca6182", size = 21575132, upload-time = "2026-09-28T13:38:31.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/58/762f7159662d7859e201fa05ca29f306795daeabf84f3e087215a966b001/duckdb-1.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:ebcbd09cd8578ab1093393e9b16289cda0e8f1791ac595bf00eb5bad75c3cf00", size = 13713963, upload-time = "2026-09-28T13:38:33.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/69/64d165db322de13f5c3e75d377b6b9694df1821155ad1fa4b14b04601abc/duckdb-1.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:820a8384faef11cd86068ea48c5da57ce2d8f1c7b3d2bdb9be3398317a7c3728", size = 14514368, upload-time = "2026-09-28T13:38:35.676Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "duckdb-backend"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "duckdb" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-ulid" },
|
||||||
|
{ name = "pytz" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "duckdb", specifier = ">=1.1.0" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27.0" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.8.0" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
|
||||||
|
{ name = "python-ulid", specifier = ">=3.0.0" },
|
||||||
|
{ name = "pytz", specifier = ">=2024.1" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.143.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-api"
|
||||||
|
version = "1.45.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.50.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-ulid"
|
||||||
|
version = "4.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/41/65079023c81491a21799c0120bce5925366b6913596bf797806f19973290/python_ulid-4.0.1.tar.gz", hash = "sha256:bbeec02556190bb9dc3401faa7268696acbfbe7b6db9908c155dc3548629f20c", size = 101683, upload-time = "2026-07-20T15:21:41.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609, upload-time = "2026-07-20T15:21:40.214Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytz"
|
||||||
|
version = "2026.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "17.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# AGENTS.md — Kitchen e-Paper Display (`esphome_display_kitchen`)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Domain**: ESP32 microcontroller driving the e-Paper display in the kitchen.
|
||||||
|
> **Source Location**: `projects/esphome_display_kitchen/`
|
||||||
|
> **Integrations**: Home Assistant Native API (`https://hass.moritzgraf.de`), Prometheus metrics client.
|
||||||
|
> **Hardware**: Waveshare e-Paper display on ESP32 (`nodemcu-32s`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Build, Flash & Messaging Tooling
|
||||||
|
|
||||||
|
- **Interactive Flashing Wizard**:
|
||||||
|
```bash
|
||||||
|
cd projects/esphome_display_kitchen
|
||||||
|
uv run flash.py
|
||||||
|
```
|
||||||
|
Provides an interactive CLI to Validate, Compile, Upload, and view Logs for `esdisplay_kitchen.yaml`.
|
||||||
|
- **Direct Terminal Messaging**:
|
||||||
|
```bash
|
||||||
|
cd projects/esphome_display_kitchen
|
||||||
|
./send_message.sh "Your custom display message"
|
||||||
|
```
|
||||||
|
Uses the Home Assistant long-lived token from `k8s/home-assistant/` to set the `input_text.epaper_message` helper in Home Assistant, which instantly pushes updates to the screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Rules & Standards
|
||||||
|
|
||||||
|
### 1. Secret Management
|
||||||
|
* **NEVER** write plaintext Wi-Fi passwords, tokens, or API keys directly into YAML configurations.
|
||||||
|
* **DO NOT** modify `.gitignore` to allow committing `.env` files.
|
||||||
|
* Use ESPHome's `!env_var VAR_NAME` to inject environment variables at compile-time. Any new secrets must be declared in `.env.template` and configured locally in `.env`.
|
||||||
|
|
||||||
|
### 2. Configuration Standards
|
||||||
|
* Board architecture: ESP32 (`nodemcu-32s` with `arduino` framework).
|
||||||
|
* SNTP time synchronization configured via `time` component for clock rendering.
|
||||||
|
* Sensor values (outdoor temperature, indoor climate) are dynamically pulled from Home Assistant entities.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user