Compare commits
143
Commits
ea6fa7344d
...
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 | ||
|
|
18a7292844 | ||
|
|
79fa817480 | ||
|
|
474af29c4d | ||
|
|
bdfd5f5057 | ||
|
|
0282b82c7a | ||
|
|
87b09ed55d | ||
|
|
e9376c6552 | ||
|
|
10a94c3512 | ||
|
|
c1cd638039 | ||
|
|
a93c1e3510 | ||
|
|
e5fa7c6ff3 | ||
|
|
ea8ce29574 | ||
|
|
2747e07148 | ||
|
|
3e0922c5fa | ||
|
|
aad2dc874d | ||
|
|
d8c95940a1 | ||
|
|
fd0633dace | ||
|
|
707a1b02ef | ||
|
|
bcff777228 | ||
|
|
ce025f4842 | ||
|
|
5c3ff03124 | ||
|
|
115ca3c80e | ||
|
|
c7d0d471b6 | ||
|
|
668f8da112 | ||
|
|
0184fc9527 | ||
|
|
97ead7943c | ||
|
|
4df5c2c3d6 | ||
|
|
feca5ccd3c | ||
|
|
6bf3bb1348 | ||
|
|
4ae16b2640 | ||
|
|
c4297d42aa | ||
|
|
edf124e88c | ||
|
|
c4c00416af |
@@ -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
|
||||
k8s/tekton-pipelines/auth
|
||||
.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/`)
|
||||
* **Purpose**: Deploying applications and services to the cluster.
|
||||
* **Method**: GitOps-style directory structure (Namespace = Folder).
|
||||
* **Constraints**: Enforces `ingress-nginx`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
|
||||
* **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` / `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)
|
||||
|
||||
### 3. Terraform (`terraform/`)
|
||||
* **Purpose**: Infrastructure as Code for external resources, primarily **DNS**.
|
||||
* **Provider**: OVH (`ovh/ovh`).
|
||||
* **Scope**:
|
||||
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others.
|
||||
* **Mail Config**: Google Workspace MX/SPF/DKIM records.
|
||||
* **Cluster IP**: Points domains to the Haumdaucher node IP (`136.243.23.215`).
|
||||
* **Usage**:
|
||||
```bash
|
||||
cd terraform
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
### 3. [Terraform](./terraform/AGENTS.md) (`terraform/`)
|
||||
* **Purpose**: Declarative core cloud infrastructure as code.
|
||||
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others via OVH (`ovh/ovh`).
|
||||
* **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`).
|
||||
* **Usage**: `cd terraform && terraform apply`
|
||||
* **Read**: [terraform/AGENTS.md](./terraform/AGENTS.md)
|
||||
|
||||
### 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).
|
||||
* **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**:
|
||||
```bash
|
||||
cd monitoring
|
||||
# Flash or update a device
|
||||
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/`)
|
||||
* **Purpose**: Shared personal knowledge wiki and task dispatch between OpenClaw (mobile/Telegram concierge) and Antigravity (local pair programming agent).
|
||||
@@ -55,13 +48,81 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
||||
3. Implement the chosen task in the appropriate repository files.
|
||||
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`.
|
||||
- When Antigravity needs knowledge about family, home, servers, or setup:
|
||||
|
||||
### 6. Architecture Decision Records (`docs/adr/`)
|
||||
* **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).
|
||||
* **Consulting ADRs**: AI agents MUST consult `docs/adr/` before designing or altering system architecture, agent topologies, or tool integration patterns.
|
||||
* **Key Records**:
|
||||
* [ADR 0001: Hybrid MCP Tool Search and Subagent Delegation for AI Agents](docs/adr/0001-hybrid-mcp-tool-search-and-subagents.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)
|
||||
|
||||
### 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
|
||||
* **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files.
|
||||
|
||||
@@ -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
|
||||
* development with Vagrant
|
||||
* host directory storage
|
||||
* Ingress via host network
|
||||
## Repository Layout
|
||||
|
||||
## 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.
|
||||
|
||||
## 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/)
|
||||
For architectural standards and operational guidelines, refer to [AGENTS.md](AGENTS.md).
|
||||
@@ -1,2 +1,4 @@
|
||||
kubespray
|
||||
venv
|
||||
credentials/
|
||||
inventory/**/credentials/
|
||||
|
||||
+64
-30
@@ -1,49 +1,83 @@
|
||||
# AGENTS.md
|
||||
|
||||
> [!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
|
||||
* **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.
|
||||
* **Idempotency**: The process is designed to be repeatable. The `kubespray` folder is treated as ephemeral and is re-created by `init.sh`.
|
||||
* **Topology**: **Single-node cluster** (`haumdaucher`, `136.243.23.215`). The single node acts simultaneously as control plane, etcd member, and worker.
|
||||
* **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
|
||||
The core workflow is encapsulated in `init.sh`.
|
||||
|
||||
### `init.sh`
|
||||
**Purpose**: Prepares the environment and Kubespray for deployment.
|
||||
**Actions**:
|
||||
1. **Clean Slate**: Deletes existing `kubespray/` directory.
|
||||
2. **Clone**: Clones Kubespray (version defined in variable `VERSION`, e.g., `release-2.27`).
|
||||
3. **Environment**: Sets up Python virtualenv via `pyenv` and installs `requirements.txt`.
|
||||
4. **Sync**: Copies local `./inventory/` configurations into `./kubespray/inventory/`.
|
||||
### 1. Source `init.sh`
|
||||
```bash
|
||||
source init.sh
|
||||
```
|
||||
**Actions performed**:
|
||||
- Clones / checks out the Kubespray branch defined by `VERSION` (`release-2.28`).
|
||||
- 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
|
||||
1. **Source the script**:
|
||||
```bash
|
||||
source init.sh
|
||||
### 2. Upgrading the Single-Node Cluster (Two-Phase Decoupled Workflow)
|
||||
|
||||
#### Phase 1: Reconcile Kubespray Tooling Baseline (Keep existing k8s version)
|
||||
```bash
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
*Verification*: Check node readiness and that no application pods crashed.
|
||||
|
||||
#### Phase 2: Upgrade Kubernetes Version (e.g. to 1.32.13)
|
||||
1. Edit `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`:
|
||||
```yaml
|
||||
kube_version: 1.32.13
|
||||
```
|
||||
2. **Deploy / Upgrade**:
|
||||
After sourcing, go to the `kubespray` directory and run the Ansible playbooks as instructed by the script output.
|
||||
* **Standard Run**:
|
||||
2. Re-sync inventory from `bootstrap/` or `bootstrap/kubespray/`:
|
||||
```bash
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
sync-inventory
|
||||
```
|
||||
* **Forced Upgrade**:
|
||||
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
|
||||
* `init.sh`: The entry point script. **Source of truth for Kubespray version.**
|
||||
* `inventory/`: Contains cluster inventory configurations (hosts, variables). **Edit this, not the one in `kubespray/`**.
|
||||
* `kubespray/`: (Ignored/Ephemeral) The checked-out Kubespray repository. **Do not edit files here directly**; they will be overwritten.
|
||||
|
||||
## 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.
|
||||
* `init.sh`: The entrypoint script (sources environment, applies patches, sets up inventory).
|
||||
* `patches/`: Monorepo patches applied automatically to ephemeral `kubespray/`.
|
||||
* `inventory/`: Source of truth for cluster inventory and variables.
|
||||
* `kubespray/`: (Ephemeral / Gitignored) Cloned Kubespray repository.
|
||||
+132
-81
@@ -1,114 +1,165 @@
|
||||
# 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
|
||||
. ./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
|
||||
cd kubespray
|
||||
vagrant up
|
||||
# up and abkle to ssh
|
||||
vagrant ssh k8s-1
|
||||
```
|
||||
## Step-by-Step Upgrade Runbook
|
||||
|
||||
## 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:
|
||||
* deactivate swap!
|
||||
* `moritz username ALL=(ALL) NOPASSWD:ALL`
|
||||
* `
|
||||
### Step 0: Pre-Flight Checks & Backups
|
||||
|
||||
```sh
|
||||
ssh centos@<ip>
|
||||
# auth via pw
|
||||
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
|
||||
```
|
||||
1. **Verify SSH and sudo access**:
|
||||
```sh
|
||||
ssh moritz@136.243.23.215 "sudo kubectl get nodes -o wide"
|
||||
```
|
||||
|
||||
Install Kubernetes:
|
||||
2. **Check Pod Health**:
|
||||
```sh
|
||||
ssh moritz@136.243.23.215 "sudo kubectl get pods -A | grep -v -E 'Running|Completed'"
|
||||
```
|
||||
|
||||
```sh
|
||||
. ./init.sh
|
||||
# follow instructions from output, sth like:
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
3. **Take a Manual etcd Snapshot**:
|
||||
```sh
|
||||
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"
|
||||
```
|
||||
|
||||
And get credentials:
|
||||
---
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
### Step 1: Reconcile Kubespray Baseline (Phase 1)
|
||||
|
||||
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
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini -e kube_version=v1.22.10 -e upgrade_cluster_setup=true cluster.yml
|
||||
# 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
|
||||
```
|
||||
2. Sync the configuration into the `kubespray/` workspace:
|
||||
```sh
|
||||
sync-inventory
|
||||
```
|
||||
|
||||
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
|
||||
ansible-playbook -i inventory/prod/inventory.ini --limit=ns3088070.ip-37-59-40.eu,ns3100058.ip-37-59-61.eu scale.yml
|
||||
ansible-playbook -i inventory/prod/inventory.ini --limit=etcd,kube-master -e ignore_assert_errors=yes cluster.yml
|
||||
```
|
||||
3. **Verify System Pods**:
|
||||
```sh
|
||||
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`.
|
||||
|
||||
```
|
||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e drain_nodes=false upgrade-cluster.yml
|
||||
```
|
||||
### Kubelet Housekeeping Stalls / Static Pod Timeout
|
||||
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
|
||||
|
||||
# check whether script is sourced
|
||||
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "script is executed, but needs to be sourced" && exit 1
|
||||
# Ensure script is sourced, not executed directly
|
||||
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "Error: init.sh must be sourced: source ./init.sh (or . ./init.sh)" && exit 1
|
||||
|
||||
echo "######################################################################################"
|
||||
echo "## Reinit repository"
|
||||
rm -rf kubespray
|
||||
VERSION="release-2.27"
|
||||
git clone --branch $VERSION https://github.com/kubernetes-sigs/kubespray.git
|
||||
BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="release-2.28" # Note: release-2.28 is the final release line supporting Ubuntu 20.04 LTS
|
||||
|
||||
echo "######################################################################################"
|
||||
echo "## Activating pyenv venv"
|
||||
echo "======================================================================================"
|
||||
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 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
|
||||
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 "######################################################################################"
|
||||
echo "## Customizing vagrant dev env"
|
||||
mkdir -p kubespray/vagrant
|
||||
cat << EOF > kubespray/vagrant/config.rb
|
||||
\$instance_name_prefix = "k8s"
|
||||
\$vm_cpus = 4
|
||||
\$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
|
||||
# Helper function for re-syncing changes made in ./inventory to ephemeral ./kubespray/inventory
|
||||
sync-inventory() {
|
||||
rsync -av "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
|
||||
}
|
||||
export -f sync-inventory 2>/dev/null || true
|
||||
alias sync-inventory="rsync -av '$BOOTSTRAP_DIR/inventory/' '$BOOTSTRAP_DIR/kubespray/inventory/'"
|
||||
|
||||
# make the rpm cache
|
||||
mkdir -p kubespray/temp/docker_rpms
|
||||
|
||||
echo "###############"
|
||||
echo "Now cd to kubespray and 'vagrant up'"
|
||||
echo "======================================================================================"
|
||||
echo "## [4/4] Environment Ready! Single-Node Cluster Upgrade Workflow"
|
||||
echo "======================================================================================"
|
||||
echo ""
|
||||
echo "export KUBECONFIG=\"$( pwd )/kubespray/inventory/sample/artifacts/admin.conf\""
|
||||
|
||||
|
||||
echo "######################################################################################"
|
||||
echo "## * syncing config in kubespray dir"
|
||||
rsync -a ./inventory/ ./kubespray/inventory/
|
||||
echo "## * changing into kubespray dir"
|
||||
cd kubespray
|
||||
echo "## * execute the following command to force new settings"
|
||||
echo "ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
|
||||
echo "## * execute the following command to force new settings with forced cluster upgrade"
|
||||
echo "ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
|
||||
|
||||
# cp -r kubespray/inventory/sample kubespray/inventory/prod
|
||||
# rm kubespray/inventory/prod/inventory.ini
|
||||
# cp ./prod.ini kubespray/inventory/prod/inventory.ini
|
||||
#gsed -i "s/kube_network_plugin: .*/kube_network_plugin: flannel/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml
|
||||
# gsed -i "s/# calico_iptables_backend: \"Legacy\"/calico_iptables_backend: \"NFT\"/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-calico.yml
|
||||
#echo 'calico_iptables_backend: "NFT"' >> ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-cluster.yml
|
||||
#gsed -i "s/metrics_server_enabled: .*/metrics_server_enabled: true/" ./kubespray/inventory/prod/group_vars/k8s-cluster/addons.yml
|
||||
echo "Current directory changed to: $BOOTSTRAP_DIR/kubespray"
|
||||
cd "$BOOTSTRAP_DIR/kubespray"
|
||||
echo ""
|
||||
echo "=== PRE-UPGRADE CHECKLIST ==="
|
||||
echo "1. Verify SSH connection and sudo access:"
|
||||
echo " ssh moritz@136.243.23.215 'sudo kubectl get nodes -o wide'"
|
||||
echo "2. Take manual etcd snapshot before upgrading:"
|
||||
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 ""
|
||||
echo "=== TWO-PHASE UPGRADE WORKFLOW ==="
|
||||
echo "Phase 1: Reconcile Kubespray baseline (keeps current k8s version):"
|
||||
echo " ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
|
||||
echo ""
|
||||
echo "Phase 2: Upgrade Kubernetes version (e.g. to 1.32.13):"
|
||||
echo " a) Update 'kube_version: <target>' in ../inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml"
|
||||
echo " b) Sync inventory: sync-inventory"
|
||||
echo " c) Run in-place single-node upgrade:"
|
||||
echo " ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
|
||||
echo ""
|
||||
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
|
||||
|
||||
## 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
|
||||
#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
|
||||
@@ -119,7 +119,30 @@ The main conductor is guided by explicit system prompt rules:
|
||||
|
||||
## Future Roadmap: Monorepo Refactoring Blueprint
|
||||
|
||||
This Hybrid MCP architecture serves as the official template for future refactorings in `infrapuzzle`:
|
||||
1. **Shopping & Supermarket Deals**: Migrate current static MCP tools behind Tool Search; delegate multi-store price comparisons and recipe cart generation to a shopping subagent.
|
||||
2. **Jellyfin & Media Management**: Keep playback/resume queries direct; delegate complex media recommendations and library reorganizations to a media subagent.
|
||||
3. **Local News & Stadtrat Research**: Provide direct search for council dates; delegate deep legislative research, PDF document extraction, and meeting summarization to an isolated Stadtrat research subagent.
|
||||
This Hybrid MCP architecture serves as the official template for all agentic workflows in `infrapuzzle`. On 2026-09-16, this architecture was formally expanded into the **Full-Spectrum Multi-Agent Architecture** with 9 specialized subagents:
|
||||
|
||||
### 1. Agents-as-Code in the Knowledge Wiki (`knowledge-wiki/agents/`)
|
||||
Subagent specifications, system prompts, tool assignments, and domain guidelines are decoupled from Kubernetes deployment manifests and stored as versioned Markdown files in `knowledge-wiki/agents/<name>.md`.
|
||||
* **Zero-Deployment Tuning**: Adding or refining an agent requires no Kubernetes pod restarts; changes synced via Git are immediately effective.
|
||||
* **Extensibility Blueprint**: Adding a 10th agent requires simply adding `agents/<new-agent>.md` with YAML frontmatter specifying model, tools, and owned concepts.
|
||||
|
||||
### 2. The 9 Specialized Subagents Roster
|
||||
1. 🩺 **Digitale Hebamme Agent** (`agents/hebamme.md`): Baby milestones, sleep windows, BLW, pediatrician safety boundaries.
|
||||
2. 🍳 **Koch Agent** (`agents/koch.md`): Ninja CREAMi Deluxe, Airfryer, KitchenAid, leftover utilization, exact recipes.
|
||||
3. 🏡 **Grabengasse Agent** (`agents/grabengasse.md`): Home Assistant, Philips Hue, Sonos multiroom, house systems.
|
||||
4. 🏰 **Regensburg Agent** (`agents/regensburg.md`): Regensburg & Stadtamhof insider, local places, RVV transit, city notices.
|
||||
5. ✈️ **Travel Agent** (`agents/travel.md`): Vacation planning, itineraries, packing checklists, on-the-road travel concierge.
|
||||
6. 🎬 **Media Agent** (`agents/media.md`): Jellyfin streaming/requests, Jellyseerr, Spotify audiobooks & playlists.
|
||||
7. 🔍 **Recherche Agent** (`agents/recherche.md`): Deep research engine (iterative multi-hop), ALLRIS Stadtrat, Friday bridge updates.
|
||||
8. 🛒 **Shopping Agent** (`agents/shopping.md`): Deal hunter, Geizhals all-time lows, supermarket deals, multi-tier webshop extraction.
|
||||
9. 🏋️ **Fitness Coach Agent** (`agents/fitness.md`): Garmin Connect (148 tools), Tacx Flux 2 (FTP 275 W), home gym supersets.
|
||||
|
||||
### 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):
|
||||
* **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.
|
||||
|
||||
### 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.
|
||||
2. **Ephemeral Inactivity Cleanup**: Subagent child sessions inactive for > 60 minutes (`subagents.archiveAfterMinutes: 60`) are automatically archived.
|
||||
3. **Deterministic Nightly Compaction**: Nightly 03:30 AM CET compaction vacuums expired child sessions from the SQLite store.
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
status: "accepted (fleet-wide primary: DeepSeek-V4.1-Flash-1017; updated 2026-10-17)"
|
||||
date: 2026-09-13
|
||||
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# ADR 0002: OpenClaw Full-Spectrum Model Selection, Multimodal Tiering, and Cost Governance
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
OpenClaw serves as the 24/7 personal AI concierge, family assistant, and operational orchestrator for Moritz Graf and his family. It interacts across Telegram, voice notes, webhooks, and background automations, bridging to personal knowledge (Obsidian wiki), smart home systems (Home Assistant, Sonos), fitness tracking (Garmin Connect), and cluster monitoring (Prometheus, Gitea).
|
||||
|
||||
During early September 2026, OpenClaw consumed **143.26 million tokens** over 7 days, accumulating **\$12.55+** in raw OpenRouter API costs. Forensic analysis revealed that over **80% of this spend was architectural waste**:
|
||||
1. **Unbounded Context Re-transmission**: An uncompacted session (`agent:main:main`) reached 381k tokens (2,650 events, 5.7 MB JSON). Every minor turn re-ingested this entire history, costing \$12.20 (97.2% of total spend).
|
||||
2. **Heartbeat Amplification Loop**: Background heartbeats firing every 30 minutes targeted the active user session (`target: last`), re-evaluating the 381k context 48 times/day simply to output `NO_REPLY` (\$5.16 wasted).
|
||||
3. **Expensive & Deprecated Model Allocations**: Frontier models (`claude-3.7-sonnet` and `deepseek-v4-pro`) were configured without clear cost-benefit boundaries, with subagents frequently timing out (>300s). In addition, legacy model generations (e.g., Gemini 2.5) lingered in configuration despite superior, newer generations being available.
|
||||
4. **Modality Conflation (Image Input vs. Image Generation)**: Vision perception (analyzing an uploaded photo) was conflated with text-to-image synthesis (drawing an image). Configuring chat models like `z-ai/glm-5.3-flash` for image synthesis caused runtime HTTP 404 failures because pure LLMs lack diffusion/raster generation heads.
|
||||
|
||||
Moritz established two foundational requirements for the repository:
|
||||
> 1. *"I want high quality, but it should not be too expensive."*
|
||||
> 2. *"We always want to use the newest models (as they usually are the best)."*
|
||||
|
||||
How do we architect the full-spectrum model selection across **all modalities** (text, subagents, heartbeats, vision, image generation, audio transcription, text-to-speech, web search, and vector embeddings) to deliver premium quality, sub-10s latency, and a total operating budget of **<\$2.00 / week**?
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **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).
|
||||
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
|
||||
- Text & Agentic Reasoning
|
||||
- Native Multimodal Vision (reading user photos)
|
||||
- Text-to-Image Generation (synthesizing new pictures)
|
||||
- Speech-to-Text / Audio Input (transcribing voice messages)
|
||||
- Text-to-Speech / Audio Output (synthesizing briefings and podcasts)
|
||||
- Web Search & Retrieval (grounded search)
|
||||
- Semantic Embeddings (vector memory)
|
||||
* **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.
|
||||
* **Resilience & Fault Tolerance**: Automated fallback chains and strict quantization/provider pins (`fp8`/`bf16`, `tool_choice` support) across independent providers on OpenRouter.
|
||||
|
||||
---
|
||||
|
||||
## Full-Spectrum Model Architecture & Concrete Allocations
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Ingress Gateway │
|
||||
└──────────────────────────────────────────────────┬─────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┬─────────────────────┼─────────────────────┬───────────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Text Conductor │ │ Subagent Engine │ │ Background & │ │ Image Synthesis │ │ Voice & Audio │
|
||||
│ & Vision Input │ │ (sessions_spawn)│ │ Heartbeat │ │ (Text-to-Image) │ │ (STT & TTS) │
|
||||
├─────────────────┤ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤
|
||||
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
|
||||
│ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │
|
||||
│ • Tool Routing │ │ • Autonomous │ │ • Zero Bleed │ │ • Dedicated │ │ • Gemini 3.1 │
|
||||
│ • Decoupled OCR │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Primary: │ │ Primary: │ │ Primary: │ │ Primary: │ │ STT: │
|
||||
│ deepseek/ │ │ deepseek/ │ │ deepseek/ │ │ google/ │ │ openai/ │
|
||||
│ deepseek-v4.1- │ │ deepseek-v4.1- │ │ deepseek-v4.1- │ │ gemini-3.1- │ │ whisper-large- │
|
||||
│ flash-1017 │ │ flash-1017 │ │ flash-1017 │ │ flash-image │ │ v3-turbo │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ TTS: │
|
||||
│ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
|
||||
│ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ flash-preview│ │ gemini-3.1- │
|
||||
│ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 2. gemini-3-pro-│ │ flash-tts │
|
||||
│ │ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
|
||||
│ Vision (OCR): │ │ │ │ │ │ │ │ Speech HD) │
|
||||
│ google/ │ │ │ │ │ │ │ │ │
|
||||
│ gemini-3.8- │ │ │ │ │ │ │ │ │
|
||||
│ flash │ │ │ │ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modality Specifications & Cost-Benefit Analysis
|
||||
|
||||
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
|
||||
* **Primary (Fleet-Wide Standard as of 2026-10-17)**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
|
||||
- **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).
|
||||
- **Provider Pinning & Tool-Compatibility Verification**:
|
||||
- Pinned `order: ["deepinfra", "cloudflare", "inference-net", "fireworks", "parasail"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]`.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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**:
|
||||
1. `openrouter/deepseek/deepseek-v4.1-flash` (`0910` baseline across 25+ providers, pinned to `deepinfra`, `streamlake`, `inference-net`, `digitalocean`, `venice`, `nebius`).
|
||||
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)
|
||||
* **All Subagents & Specialists**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
|
||||
- **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` / `openrouter/z-ai/glm-5.3-flash` / `openrouter/qwen/qwen3.8-flash`.
|
||||
|
||||
### 3. Background Daemon & Heartbeats (Tier 3)
|
||||
* **Configuration**: `heartbeat.model: "openrouter/deepseek/deepseek-v4.1-flash-1017"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
|
||||
* **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)
|
||||
* **Crucial Distinction**:
|
||||
- **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.
|
||||
* **Image Synthesis Engine**:
|
||||
- **Primary**: `openrouter/google/gemini-3.1-flash-image` (Nano Banana 2).
|
||||
- **Cost**: \$0.00006 per generated image + \$0.50/1M prompt.
|
||||
- **Latency**: ~4.5 seconds.
|
||||
- **Fallbacks**:
|
||||
1. `openrouter/google/gemini-3.1-flash-image-preview`
|
||||
2. `openrouter/google/gemini-3-pro-image` (Nano Banana Pro)
|
||||
- **Decommissioned**: Gemini 2.5 is completely removed from all image configurations.
|
||||
|
||||
### 5. Voice, Audio & Podcast Synthesis (Tier 5)
|
||||
* **Speech-to-Text (Audio Input)**:
|
||||
- **Engine**: `openrouter/openai/whisper-large-v3-turbo`
|
||||
- **Capabilities**: Fast, highly accurate transcription of German and English Telegram voice notes, with local gateway fallback.
|
||||
* **Text-to-Speech (Audio Dossiers & Podcasts)**:
|
||||
- **Engine**: `google/gemini-3.1-flash-tts-preview` via `text-to-audio` skill.
|
||||
- **Voices**:
|
||||
- German Solo/Host: `Puck` (warm, natural, dynamic).
|
||||
- German Deep/Calm: `Charon`.
|
||||
- German Co-Host / Expert: `Aoede`.
|
||||
- English: `Zephyr`.
|
||||
- **Fallback**: `minimax/speech-2.8-hd` for studio-grade multi-speaker podcasts when Google TTS is throttled.
|
||||
|
||||
### 6. Grounded Web Search & Vector Embeddings
|
||||
* **Live Search**: `perplexity/sonar-pro` via OpenRouter (replacing raw web scraping with grounded citations).
|
||||
* **Memory Embeddings**: `text-embedding-3-small` via OpenRouter for the Obsidian personal knowledge wiki.
|
||||
|
||||
---
|
||||
|
||||
## Context Lifecycle & Daytime Headroom Policy
|
||||
|
||||
To balance the user's desire to retain full conversation context across the entire day while avoiding the 70-second latency penalty that uncompacted transcripts impose on OpenRouter providers:
|
||||
|
||||
1. **Daytime Headroom (`maxActiveTranscriptBytes: "3mb"`)**:
|
||||
The active transcript is permitted to retain the day's back-and-forth interactions so OpenClaw recalls earlier topics and instructions naturally.
|
||||
2. **Deterministic Nightly Compaction**:
|
||||
Scheduled daily at **03:30 AM Europe/Berlin** (`ce39bbf8-b83f-453e-be53-04e27db27b78`):
|
||||
```bash
|
||||
openclaw sessions compact "agent:main:main" --max-lines 250
|
||||
```
|
||||
Executes in **2.1 seconds** using **0 LLM tokens**, ensuring every morning begins with a lean, fast ~15k token transcript.
|
||||
|
||||
---
|
||||
|
||||
## Open Clarifying Questions for Moritz
|
||||
|
||||
To align on remaining edge cases before final sign-off:
|
||||
|
||||
1. **Active Trial with GLM-5.3-Flash as Primary Conductor**:
|
||||
- As requested, `z-ai/glm-5.3-flash` is now deployed as the Primary Conductor for interactive chat and autonomous subagents, equipped with the prompt guardrails against direct tool calls and XML leakage.
|
||||
- `deepseek/deepseek-v4.1-flash` stands ready as the zero-latency primary fallback.
|
||||
- We will observe real-world performance, tool adherence, and cost over the next several days.
|
||||
2. **Thinking / Reasoning Mode on Telegram**:
|
||||
- Currently, `thinking: medium` is enabled, emitting 100–300 reasoning tokens before generating conversational text.
|
||||
- *Question*: Would you like to disable thinking mode (`thinking: none`) for standard Telegram chat turns (for near-instant replies) and keep reasoning active only for subagents?
|
||||
3. **On-Demand Frontier Model**:
|
||||
- With all standard workloads handled by Flash-tier models, do you ever want an explicit escape hatch to a frontier model (e.g. `claude-3.7-sonnet` or `gemini-3.1-pro-preview` triggered exclusively via `/expert <query>`)?
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
### Positive
|
||||
* **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.
|
||||
* **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 ~\$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
|
||||
* **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*: 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`).
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
status: "accepted"
|
||||
date: 2026-09-17
|
||||
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The **Infrapuzzle** monorepo governs the complete digital infrastructure for Moritz Graf and family: bare-metal server provisioning (Haumdaucher node), Kubernetes workloads, DNS management, mail routing, smart home/IoT edge devices, and AI concierge workflows (OpenClaw and specialized domain subagents).
|
||||
|
||||
Historically, home labs and personal infrastructure suffer from the "snowflake" problem:
|
||||
1. **Undocumented ClickOps**: Settings altered manually in cloud provider consoles (e.g. OVH DNS, router web UIs) or via web dashboards (Home Assistant, Grafana) become unversioned, undocumented, and fragile.
|
||||
2. **Ephemeral Terminal Interventions**: Ad-hoc `kubectl edit`, hot-patched container environments, or manual secret injections deviate from repository state, rendering disaster recovery nearly impossible.
|
||||
3. **Agent Prompt Fragmentation**: As AI agents proliferate (e.g. OpenClaw conductor, domain subagents like Hebamme, Koch, Grabengasse, Fitness, Recherche), defining prompts or tool configs across disparate Helm values, ConfigMaps, and local files leads to inconsistencies, stale behavior, and duplicate definitions.
|
||||
|
||||
How do we establish a strict, maintainable architectural standard that ensures **everything is defined as code as far as feasible and reproducible**, while accommodating dynamic runtime state and secrets safely?
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Determinism & Disaster Recovery**: The entire single-node cluster (`haumdaucher.de`), along with all services, DNS records, and edge devices, must be rebuildable from scratch purely from git repositories.
|
||||
* **Single Source of Truth**: Eliminate configuration drift across all operational domains.
|
||||
* **Exclusive Agent Storage**: Establish an unambiguous, single home for all AI agent personas, system prompts, role boundaries, and tool guidance.
|
||||
* **Declarative Secret Management**: Store all secrets in version control without risking plain-text credential leaks.
|
||||
* **Pragmatic Feasibility Boundary**: Clearly separate declarative desired state (in Git) from legitimate mutable runtime data (databases, dynamic chat histories, live OAuth session tokens) without breaking stateful applications.
|
||||
|
||||
---
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Option 1: Ad-Hoc / Best-Effort Setup (Status Quo Antipattern)**
|
||||
* Store major K8s manifests in git, but allow manual tweaks in web consoles (OVH, Home Assistant, Shelly) and ad-hoc `kubectl` patches.
|
||||
* *Drawbacks*: Severe drift, snowflake server syndrome, impossible disaster recovery, zero audit trail.
|
||||
|
||||
2. **Option 2: Pure GitOps Immutability with Zero Runtime Mutation**
|
||||
* Force every state mutation (including session compacting, OAuth token refreshes, and dynamic settings) to generate a Git commit.
|
||||
* *Drawbacks*: Impractical for stateful workloads. High friction, commit noise, rate limits on git providers, and potential race conditions when multi-agent systems rapidly update local session caches.
|
||||
|
||||
3. **Option 3: Comprehensive Everything-as-Code with Pragmatic State Governance (Selected)**
|
||||
* Define 100% of infrastructure, workloads, DNS, IoT edge devices, and AI agents declaratively in Git.
|
||||
* Enforce `git-crypt` for declarative secrets.
|
||||
* Isolate mutable runtime state (databases, chat histories, active OAuth tokens) to persistent volumes (PVCs) governed by zero-drift merge mechanics (`mergeMode: merge`).
|
||||
* Mandate `knowledge-wiki/agents/` as the **exclusive** home for all AI agent prompt definitions.
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen Option: **Option 3: Comprehensive Everything-as-Code with Pragmatic State Governance**.
|
||||
|
||||
### 1. The Five Tiers of Everything as Code (EaC)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Infrapuzzle Monorepo (Git) │
|
||||
└────────────────────────────────────────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┬───────────────────────┼───────────────────────┬──────────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
|
||||
│Bare-Metal │ │Kubernetes │ │Cloud & DNS│ │Smart Home │ │Agents as │
|
||||
│Bootstrap │ │Workloads │ │ │ │& IoT Edge │ │Code (Wiki)│
|
||||
├───────────┤ ├───────────┤ ├───────────┤ ├───────────┤ ├───────────┤
|
||||
│• Kubespray│ │• Helm │ │• Terraform│ │• ESPHome │ │• Exclusive│
|
||||
│• Ansible │ │• Manifests│ │ (OVH DNS)│ │• Shelly │ │ prompts │
|
||||
│• init.sh │ │• Operators│ │• Mail DNS │ │ Terraform│ │• Tool │
|
||||
│ │ │ (OpenClaw│ │ (MX/SPF/ │ │• BLE & │ │ bindings │
|
||||
│ │ │ CRDs) │ │ DKIM) │ │ Sensors │ │• git-sync │
|
||||
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
Hetzner Node Haumdaucher K8s Public DNS & Grabengasse 7 OpenClaw
|
||||
Base OS Setup Cluster Engine Domain Records Physical Devices Conductor
|
||||
```
|
||||
|
||||
#### Tier 1: Bare-Metal Server Provisioning (`bootstrap/`)
|
||||
* **Tooling**: Kubespray, Ansible, Bash (`init.sh`).
|
||||
* **Scope**: Operating system hardening, kernel modules, firewall rules, container runtime (containerd), and Kubernetes control plane setup on the single bare-metal node (Haumdaucher, `136.243.23.215`).
|
||||
* **Source of Truth**: `bootstrap/init.sh` and version-pinned Kubespray playbooks.
|
||||
|
||||
#### Tier 2: Kubernetes Workloads & Operators (`k8s/`)
|
||||
* **Tooling**: Raw Kubernetes manifests, Helm 3 values, and Kubernetes Custom Resource Definitions (CRDs).
|
||||
* **Conventions**:
|
||||
- Directory structure enforces namespace partitioning (`k8s/<namespace>/`).
|
||||
- Workloads managed via declarative specifications (e.g. OpenClaw Operator `OpenClawInstance` CRD in `k8s/openclaw/openclaw-instance.secret.yaml`).
|
||||
- Standardized ingress controller (`ingress-nginx` / `traefik`) with automated Let's Encrypt certificates (`cert-manager`).
|
||||
|
||||
#### Tier 3: External Infrastructure, DNS & Mail (`terraform/`)
|
||||
* **Tooling**: Terraform with `ovh/ovh` provider.
|
||||
* **Scope**:
|
||||
- Authoritative DNS zones (`haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others).
|
||||
- Google Workspace mail routing (MX, SPF TXT, DKIM, DMARC records).
|
||||
- Cluster IP mapping to the bare-metal ingress endpoint.
|
||||
* **Execution**: Fully automated via `terraform plan` / `terraform apply` directly from version-controlled state.
|
||||
|
||||
#### Tier 4: Smart Home & IoT Edge Hardware (`projects/shelly_taupunktluefter/`, `esphome/`, `monitoring/`)
|
||||
* **Tooling**: ESPHome, Shelly Terraform Provider, YAML configs.
|
||||
* **Scope**:
|
||||
- Shelly relays and smart switches provisioned declaratively via Terraform.
|
||||
- ESPHome firmware configurations for ESP32 Bluetooth proxies, Xiaomi BLE temperature/hygrometer sensors, and plant moisture monitors.
|
||||
- All device configurations, pinouts, and network parameters committed to git; flashing performed deterministically from code.
|
||||
|
||||
#### Tier 5: Agents-as-Code & Knowledge Systems (`knowledge-wiki/agents/`)
|
||||
* **Core Rule — Exclusive Single Source of Truth**:
|
||||
> **All AI agent definitions (prompts, personas, operational boundaries, and tool calling instructions) MUST reside exclusively in [`knowledge-wiki/agents/<name>.md`](../../knowledge-wiki/agents/).**
|
||||
* **Strict Architecture**:
|
||||
- Agent behavior is never hardcoded inside container images, ad-hoc Helm values, or untracked local directories.
|
||||
- Adding or modifying an agent is done purely by creating or editing a markdown specification in the `knowledge-wiki/agents/` directory (e.g. `hebamme.md`, `koch.md`, `grabengasse.md`, `fitness.md`, `recherche.md`).
|
||||
- Workloads (such as OpenClaw) ingest these definitions dynamically via `git-sync` sidecars or submodules, enabling instantaneous updates without requiring pod restarts or image rebuilds.
|
||||
- If agent prompts or specifications are discovered outside of `knowledge-wiki/agents/`, they must be refactored and consolidated into this directory.
|
||||
|
||||
---
|
||||
|
||||
## The Feasibility Boundary & State Governance
|
||||
|
||||
The user requirement explicitly dictates: *"we want to do everything as code as far as it is feasible and reproducible"*. To balance pure declarative code with stateful services, the boundary is defined as follows:
|
||||
|
||||
| Dimension | Managed as Code (Git) | Managed on Persistent Storage (PVC) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Workload Topology** | Pod specs, replicas, resource limits, CRDs, Helm releases. | — |
|
||||
| **Secrets & Keys** | Encrypted via `git-crypt` (`*.secret`, `*.secret.yaml`, `*.secret.values`). | Mounted into runtime memory/containers. |
|
||||
| **Agent Behavior** | System prompts, personas, roles, tool manifests in `knowledge-wiki/agents/`. | Dynamic short-term conversation context, user memory (`MEMORY.md`). |
|
||||
| **External Auth Tokens** | Initial static API keys and client credentials. | Volatile session tokens refreshed dynamically at runtime (e.g. Garmin OAuth tokens on PVC). |
|
||||
| **Data Storage** | Schema migrations, DB connection configs, backup cronjobs. | Raw databases (PostgreSQL, SQLite), media files, Nextcloud files. |
|
||||
| **Operator Integration** | Declarative base configurations (`spec.config.raw`). | Runtime mutations deep-merged safely via `mergeMode: merge` without overwriting runtime state. |
|
||||
|
||||
### Operational Governance Rules
|
||||
|
||||
1. **No Ad-Hoc `kubectl edit`**: Never modify cluster objects directly with `kubectl edit` or unrecorded commands. All modifications must be committed to git and applied via `kubectl apply -f <manifest>` or Helm.
|
||||
2. **Git-Crypt Security Enforcement**: Any file containing API keys, private passwords, or private identities must match the `.gitattributes` pattern (`*.secret*`) and be locked with `git-crypt`.
|
||||
3. **Reproducibility Guarantee**: In the event of total hardware loss, cloning `infrapuzzle` and unlocking `git-crypt` must be sufficient to run `bootstrap/init.sh`, apply Terraform, restore PVC backups from Velero/MinIO, and restore full operational state.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
* **100% Deterministic Disaster Recovery**: Eliminates mysterious configuration drift and allows full rebuilds of bare-metal, Kubernetes, DNS, and IoT systems.
|
||||
* **Unified AI Agent Lifecycle**: Developers and AI assistants edit agent personalities, prompts, and skills in a single, version-controlled markdown directory (`knowledge-wiki/agents/`).
|
||||
* **High Operational Velocity**: Changes to agent logic apply instantly via `git-sync` without container restarts or downtime.
|
||||
* **Audit Trail**: Every change to infrastructure, DNS, smart home switches, or agent system prompts has an author, timestamp, and commit message.
|
||||
|
||||
### Negative & Mitigations
|
||||
* **Discipline Overhead**: Even minor configuration changes require a git commit and deployment workflow rather than a quick UI click.
|
||||
* *Mitigation*: Tooling like OpenClaw Operator automated rolling updates and `git-sync` minimizes deployment friction to a single `git push`.
|
||||
* **Git-Crypt Dependency**: Operators and agents require the repository encryption key to modify secrets.
|
||||
* *Mitigation*: Standardized symmetric key distribution and clear documentation in `k8s/AGENTS.md`.
|
||||
@@ -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.
|
||||
@@ -11,6 +11,10 @@ We follow the [Markdown Architectural Decision Records (MADR)](https://adr.githu
|
||||
| ADR | Title | Status | Date | Area |
|
||||
| :--- | :--- | :---: | :---: | :--- |
|
||||
| [**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 |
|
||||
| [**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)
|
||||
|
||||
## Directory Structure
|
||||
* **Top-level folders**: Each folder corresponds to a Kubernetes **namespace**.
|
||||
* Example: `mailu/` contains resources for the `mailu` 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, `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.
|
||||
|
||||
## 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)
|
||||
docker login registry.haumdaucher.de -u moritz -p "PPf10musSh!!"
|
||||
|
||||
# Build and push backend image
|
||||
cd website/grabengasse_backend
|
||||
make all
|
||||
# Build and push images via root projects/grabengasse Makefile
|
||||
cd projects/grabengasse
|
||||
make build push
|
||||
|
||||
# Build and push frontend image
|
||||
cd ../grabengasse_frontend
|
||||
make all
|
||||
# Or build individual components:
|
||||
cd projects/grabengasse/frontend && make all
|
||||
cd ../backend && make all
|
||||
```
|
||||
|
||||
### Deploy to Kubernetes
|
||||
|
||||
@@ -25,4 +25,5 @@ spec:
|
||||
configMap:
|
||||
name: rclone-gdrive-config
|
||||
restartPolicy: Never
|
||||
backoffLimit: 100
|
||||
activeDeadlineSeconds: 86400
|
||||
backoffLimit: 3
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,108 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: hosting-add-prefix
|
||||
namespace: minio
|
||||
spec:
|
||||
addPrefix:
|
||||
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
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio-hosting
|
||||
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: "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:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: hosting.haumdaucher.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- hosting.haumdaucher.de
|
||||
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
|
||||
Binary file not shown.
@@ -57,7 +57,7 @@ The dashboard is exposed on your custom domain with TLS and basic authentication
|
||||
* **Username:** `admin`
|
||||
* **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`.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
+110
-2
@@ -25,6 +25,23 @@ The multi-container pod structure is managed natively by the operator controller
|
||||
| `skill-stabilizer` | Retained under `spec.sidecars` to copy skills to the flat PVC structure |
|
||||
| `install-uv-python` (init) | Managed natively by the operator under `spec.runtimeDeps.python: true` |
|
||||
|
||||
### 3. Multi-Agent Architecture & Agents-as-Code (ADR 0001)
|
||||
* **Conductor**: The main session runs as the lean front-of-house coordinator (`SOUL.md`), handling fast actions (< 2s) directly with MCP tools and delegating complex domain tasks via `sessions_spawn`.
|
||||
* **Specialist Subagents & Telegram Forum Topics**: 10 specialized subagents defined as code in the knowledge wiki and pinned to Telegram forum topics:
|
||||
- 🏋️ `agents/fitness.md` (Topic 20 "Sport" — Garmin Connect 148 tools, Tacx Flux 2, home gym supersets)
|
||||
- 🍳 `agents/koch.md` (Topic 21 "Kochen" — Ninja CREAMi Deluxe, Airfryer, KitchenAid, leftover utilization)
|
||||
- 🩺 `agents/hebamme.md` (Topic 22 "Mila" — Baby development & sleep, pediatrician safety boundaries)
|
||||
- ✈️ `agents/travel.md` (Topic 51 "Travel" — Vacation itineraries, packing lists, travel concierge)
|
||||
- 🛒 `agents/shopping.md` (Topic 115 "Shopping" — Supermarket deals, Geizhals best prices, Bring! lists)
|
||||
- 🏰 `agents/regensburg.md` (Topic 116 "Regensburg" — Stadtamhof insider, RVV, playgrounds, local dining)
|
||||
- 🔍 `agents/recherche.md` (Topic 117 "Research" — Deep research, ALLRIS Stadtrat, Friday bridge tracking)
|
||||
- 👨⚕️ `agents/doktor.md` (Topic 118 "Medizin" — Evidenzbasierte ärztliche Beratung, Laborwerte, Red-Flag Triage)
|
||||
- 🧑💻 `agents/itguy.md` (Topic 119 "IT Guy" — Nerdy CS Hacker, K8s Ops via `kubectl` & RBAC, Git PRs, Prometheus Alert Responder)
|
||||
- 🏡🎬 `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.
|
||||
* **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 clean conversational context.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Applying Configuration Changes
|
||||
@@ -38,12 +55,103 @@ To perform modifications or rotate api keys:
|
||||
kubectl apply -f k8s/openclaw/openclaw-instance.secret.yaml
|
||||
kubectl apply -f k8s/openclaw/cronjob-wiki-backup.secret.yaml
|
||||
```
|
||||
4. **Register / Sync Automations**: Run the automation setup scripts to register OpenClaw Cron tasks:
|
||||
```bash
|
||||
./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.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting & Live Verification
|
||||
### 4. 🗂️ The Three Core Repositories Architecture & Weekly Self-Audit
|
||||
|
||||
If the agent is offline or failing:
|
||||
OpenClaw connects to three foundational Git repositories:
|
||||
1. **`infrapuzzle`** (`/home/node/.openclaw/repos/infrapuzzle` on PVC):
|
||||
- **Scope**: Source of truth for cluster infrastructure (`haumdaucher`), Kubespray bootstrap, K8s manifests, Terraform DNS, ESPHome IoT, and Architecture Decision Records (`docs/adr/`).
|
||||
2. **`mop-skills`** (`/home/node/mop-skills/mop-skills` via `git-sync` sidecar):
|
||||
- **Scope**: Custom Python skills, CLI utilities, and MCP bridges (`openclaw-audit`, `garmin-auth`, `brother-printer`, `google-calendar`, etc.).
|
||||
3. **`knowledge-wiki`** (`/home/node/.openclaw/wiki/main`):
|
||||
- **Scope**: Agents-as-code (`agents/*.md`), active task dispatch queue (`tasks/antigravity.md`), family knowledge vault, and audit archives (`operations/audits/`).
|
||||
|
||||
#### 🩺 Autonomous Weekly Health Audit (`itguy-weekly-health-audit`)
|
||||
- Scheduled via OpenClaw Cron on **Sundays at 03:00 Europe/Berlin** (`0 3 * * 0`).
|
||||
- Executes `uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push`.
|
||||
- Correlates errors (pod stdout & session transcripts) with recent git commits across the 3 repositories.
|
||||
- Archives diagnostic reports into `operations/audits/YYYY-Wxx.md`.
|
||||
- Appends newly detected, actionable tasks into `tasks/antigravity.md` under `## 📥 Pending Tasks`.
|
||||
- **Telegram Notification Policy**: Alerts Topic `#IT Guy` (thread `119`) if new issues are filed; returns `NO_REPLY` for intentional silence when clean.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting & Incident Playbook
|
||||
|
||||
> [!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
|
||||
Ensure that the `OpenClawInstance` is successfully reconciled:
|
||||
|
||||
@@ -20,6 +20,11 @@ The deployment is managed by the official **OpenClaw Operator** (`openclaw-opera
|
||||
* **Vector Search**: Semantic memory search via OpenRouter `/v1/embeddings` (`text-embedding-3-small`).
|
||||
* **Memory Wiki**: Bundled `memory-wiki` compiles structured entities, concepts, and dashboards into an Obsidian-compatible vault (`wiki/main`).
|
||||
* **Automated Backup**: `openclaw-wiki-backup` CronJob runs daily at 03:30 AM UTC to commit and push the knowledge vault to `git.moritzgraf.de/moritz/knowledge-wiki`.
|
||||
* **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`).
|
||||
* **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 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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +54,9 @@ kubectl apply -f k8s/openclaw/openclaw-instance.secret.yaml
|
||||
|
||||
# Apply the automated daily knowledge-wiki backup CronJob
|
||||
kubectl apply -f k8s/openclaw/cronjob-wiki-backup.secret.yaml
|
||||
|
||||
# Register the IT Guy weekly health audit automation
|
||||
./k8s/openclaw/setup-itguy-cron.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
BIN
Binary file not shown.
@@ -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.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: openclaw-cluster-ops
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: openclaw
|
||||
namespace: openclaw
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: cluster-admin
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -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
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# setup-itguy-cron.sh
|
||||
# Declaratively registers the IT Guy weekly health audit in OpenClaw.
|
||||
# Schedule: Every Sunday at 03:00 Europe/Berlin (cron: 0 3 * * 0)
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAMESPACE="${NAMESPACE:-openclaw}"
|
||||
POD_NAME="${POD_NAME:-openclaw-0}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
|
||||
|
||||
JOB_NAME="itguy-weekly-health-audit"
|
||||
CRON_SCHEDULE="0 3 * * 0"
|
||||
TIMEZONE="Europe/Berlin"
|
||||
TELEGRAM_GROUP="-1004326921510"
|
||||
TELEGRAM_TOPIC="119"
|
||||
|
||||
echo "=== Registering IT Guy Weekly Health Audit in OpenClaw ($POD_NAME) ==="
|
||||
|
||||
# 1. Check if job already exists
|
||||
EXISTING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list 2>/dev/null | grep "$JOB_NAME" | awk '{print $1}' || 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 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).
|
||||
EOF
|
||||
)
|
||||
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||
--name "$JOB_NAME" \
|
||||
--description "Wöchentlicher Self-Audit der OpenClaw-Logs, Tool-Failures und Session-Transkripte mit Git-Commit-Korrelation" \
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--agent "itguy" \
|
||||
--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
|
||||
Binary file not shown.
@@ -41,16 +41,16 @@ spec:
|
||||
selector:
|
||||
app: moritzgrafde
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: moritzgrafde
|
||||
namespace: web
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- "moritzgraf.de"
|
||||
@@ -60,6 +60,9 @@ spec:
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
serviceName: moritzgrafde
|
||||
servicePort: 80
|
||||
service:
|
||||
name: moritzgrafde
|
||||
port:
|
||||
number: 80
|
||||
|
||||
@@ -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: 5e0295ba1e...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" },
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user