Compare commits
2
Commits
7ce1da18ab
...
c33b6e94fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33b6e94fb | ||
|
|
dd60a095c6 |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: "accepted (trial: GLM-5.3-Flash)"
|
||||
status: "accepted (fleet-wide primary: DeepSeek-V4.1-Flash-1017; updated 2026-10-17)"
|
||||
date: 2026-09-13
|
||||
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||
consulted: []
|
||||
@@ -28,7 +28,7 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. Gemini 3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
|
||||
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
|
||||
* **Conversational & Reasoning Quality**: High emotional intelligence, nuanced German fluency, personal context recall, and strict adherence to tool-calling protocols (ADR 0001).
|
||||
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
|
||||
- Text & Agentic Reasoning
|
||||
@@ -38,9 +38,9 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
- Text-to-Speech / Audio Output (synthesizing briefings and podcasts)
|
||||
- Web Search & Retrieval (grounded search)
|
||||
- Semantic Embeddings (vector memory)
|
||||
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), zero background bleed, and predictable billing.
|
||||
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), high prompt-cache discount efficiency (`cacheRead`), zero background bleed, and predictable billing.
|
||||
* **Latency & Turn Responsiveness**: Conversational turns on Telegram must return within **4–10 seconds**, avoiding provider queuing or massive prefill bottlenecks.
|
||||
* **Resilience & Fault Tolerance**: Automated fallback chains across independent providers on OpenRouter.
|
||||
* **Resilience & Fault Tolerance**: Automated fallback chains and strict quantization/provider pins (`fp8`/`bf16`, `tool_choice` support) across independent providers on OpenRouter.
|
||||
|
||||
---
|
||||
|
||||
@@ -60,22 +60,23 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
│ 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 │
|
||||
│ • Native Vision │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
|
||||
│ • Decoupled OCR │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Primary (Trial):│ │ Primary (Trial):│ │ Primary: │ │ Primary: │ │ STT: │
|
||||
│ z-ai/ │ │ z-ai/ │ │ z-ai/ │ │ google/ │ │ openai/ │
|
||||
│ glm-5.3-flash │ │ glm-5.3-flash │ │ glm-5.3-flash │ │ gemini-3.1- │ │ whisper-large- │
|
||||
│ │ │ │ │ │ │ flash-image │ │ v3-turbo │
|
||||
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ │ │
|
||||
│ 1. deepseek- │ │ 1. deepseek- │ │ 1. deepseek- │ │ Fallbacks: │ │ TTS: │
|
||||
│ v4.1-flash │ │ v4.1-flash │ │ v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
|
||||
│ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ flash-preview│ │ gemini-3.1- │
|
||||
│ │ │ │ │ │ │ 2. gemini-3-pro-│ │ flash-tts │
|
||||
│ Vision Fallback:│ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
|
||||
│ google/ │ │ │ │ │ │ │ │ Speech HD) │
|
||||
│ 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 │ │ │ │ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
@@ -86,27 +87,31 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
## Modality Specifications & Cost-Benefit Analysis
|
||||
|
||||
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
|
||||
* **Primary (Active Trial)**: `openrouter/z-ai/glm-5.3-flash`
|
||||
- **Pricing**: \$0.075 / 1M prompt tokens; \$0.300 / 1M completion tokens. This is 50–75% more cost-effective than DeepSeek V4.1-Flash and offers a 1.3M token context window. Under daytime context headroom (~25k–60k tokens), daily cost is only **~\$0.01–\$0.02 / day**.
|
||||
- **Performance & Reasoning**: SOTA Chinese & English reasoning, competitive coding benchmarks, and native vision perception.
|
||||
- **Forensic Diagnosis & Tool-Calling Protocol Fix**:
|
||||
- *Incident*: On September 14, 2026, GLM-5.3-Flash emitted raw `<tool_call>supermarket__supermarket_search...</tool_call>` XML into Telegram instead of executing tools.
|
||||
- *Root Cause Identified*: In Zhipu AI's pre-training tokenizer, function calls are emitted as special XML tokens. When models are queried through OpenRouter's OpenAI-compatible API, OpenRouter only intercepts and translates `<tool_call>` tokens into JSON if the tool `name` exists in the request's registered `tools: [...]` array. OpenClaw uses dynamic Tool Search (`tools.toolSearch.mode: "directory"`) where only 3 control tools (`tool_search`, `tool_describe`, `tool_call`) are registered in `tools`. When GLM received deferred schemas (e.g., `supermarket__supermarket_search`), its pre-training prior prompted it to call the tool directly. Because `supermarket__supermarket_search` was not in OpenRouter's `tools` array, OpenRouter passed the raw XML through as message text. Once that text entered the session history (events SEQ 297 & 306), GLM was in-context primed to repeat the XML pattern.
|
||||
- *Remediation & Verification*: We introduced an explicit deferred tool execution protocol into `AGENTS.md` instructing the model that deferred tools must NEVER be called directly and MUST be routed via `tool_call({ id: 'tool_name', args: { ... } })`. Direct OpenRouter API probes confirmed that with this rule, GLM-5.3-Flash calls `tool_call` with 100% compliant JSON and zero XML leakage. Session events were sanitized.
|
||||
* **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`: Rock-solid JSON tool-calling and sub-3s response times via dedicated OpenRouter ASIC cluster, available immediately if GLM experiences any upstream provider queuing or issues.
|
||||
2. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
|
||||
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)
|
||||
* **General Subagents**: `openrouter/z-ai/glm-5.3-flash`
|
||||
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Inherits the `tool_call` protocol guardrails from `AGENTS.md`.
|
||||
* **Deep Analysis & IT Specialists (`itguy`, `recherche`)**: `openrouter/deepseek/deepseek-v4.1-flash`
|
||||
- High-precision reasoning, robust nested tool loops without syntax drift, and immune to XML leakage on extensive research queries or CLI diagnostics.
|
||||
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/qwen/qwen3.8-flash`.
|
||||
* **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/z-ai/glm-5.3-flash"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
|
||||
* **Rationale**: Decoupled completely from user conversation transcripts. Polling occurs against an isolated, minimal prompt (~400 tokens), dropping weekly heartbeat token burn from 21,000,000 to <150,000 tokens (<\$0.02 / week).
|
||||
* **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**:
|
||||
@@ -171,14 +176,83 @@ To align on remaining edge cases before final sign-off:
|
||||
|
||||
---
|
||||
|
||||
## OpenRouter Provider Qualification Criteria & Re-Evaluation Playbook
|
||||
|
||||
Whenever a new model snapshot (such as `deepseek/deepseek-v4.1-flash-1017`) is evaluated or re-checked as new hosting providers join OpenRouter, every candidate provider MUST pass the following **7-point qualification gate** before being added to `extra_body.provider.order` in [openclaw-instance.secret.yaml](file:///Users/moritz/src/infrapuzzle/k8s/openclaw/openclaw-instance.secret.yaml):
|
||||
|
||||
### 1. Hard Technical Gates (Must Pass All)
|
||||
1. **Quantization Precision (`fp8` / `bf16` Only)**:
|
||||
- **Rule**: Must run `fp8` or `bf16` (or verified full-precision `unknown` from trusted cloud hosts like Cloudflare).
|
||||
- **Reject**: Any `fp4`, `nvfp4`, `mxfp4`, or `int4` quantization (`decart`, `sail-research`, `wafer`, `open-inference`, `phala` fp4). 4-bit quantizations severely degrade nested JSON tool-call syntax and German grammar.
|
||||
2. **OpenRouter Routing Funnel & Guardrail Compatibility (`tools` + `tool_choice: "auto"` Pass)**:
|
||||
- **Rule**: Must not be stripped by OpenRouter's internal routing filters (`Filter by Tool Compatibility` or `Filter by Guardrails`) when a request includes `tools: [...]` and `tool_choice: "auto"`.
|
||||
- **Why**: Metadata in `/api/v1/models/.../endpoints` can claim `supports_tool_choice: true` even when OpenRouter's live router blocks the provider on actual tool calls (e.g. `morph/fp8` is stripped by `Filter by Tool Compatibility`, and `deepseek` official is stripped by `Paid model training violation` account privacy settings).
|
||||
3. **Nested MCP `tool_call` Serialization Integrity (2/2 Live Pass)**:
|
||||
- **Rule**: Must cleanly emit `finish_reason: "tool_calls"` with valid, parseable nested JSON arguments (`{"id": "mcp:...", "args": {...}}`) across back-to-back test runs without mid-stream SSE aborts (`Provider returned an incomplete or malformed tool call`) or HTTP `429` upstream rate limits.
|
||||
4. **Context Window & Completion Headroom**:
|
||||
- **Rule**: `context_length >= 262,144` (1,048,576 preferred) and `max_completion_tokens >= 131,072`.
|
||||
- **Why**: Active daytime sessions with system prompts and tool schemas reach 20k–80k tokens.
|
||||
5. **Uptime & Reliability**:
|
||||
- **Rule**: `uptime_last_1d >= 95%` and `uptime_last_30m == 100%`.
|
||||
|
||||
### 2. Economic & Latency Ranking (How to Order Passing Providers)
|
||||
Among providers passing all 5 technical gates, rank them in `extra_body.provider.order` by **effective production cost** and **turn latency**:
|
||||
1. **Implicit Prompt Cache Read Rate (`input_cache_read`) — Highest Weight**:
|
||||
- Because **73%–94%** of OpenClaw's token volume (both interactive multi-turn chats and 30m background heartbeats) consists of cached prompt reads (`cacheRead`), a provider with `supports_implicit_caching: true` and a **95%–98% cache discount** (`<= $0.005 / 1M` tokens, e.g. `deepinfra` at `$0.00294/M` or `streamlake` at `$0.0030/M`) is vastly cheaper in net monthly spend than a provider with slightly cheaper uncached input but weak or missing `cacheRead` discounts (e.g. `nebius` with `cache_read: null` or `modal`/`primeintellect` at `$0.030/M`).
|
||||
2. **Base Prompt & Completion Pricing**:
|
||||
- Target `prompt <= $0.18 / 1M` and `completion <= $0.65 / 1M` for top-priority slots; allow higher-priced providers (`<= $0.30 / $1.20`) only in lower fallback slots (#3/#4) for outage resilience.
|
||||
3. **End-to-End Tool-Call Latency**:
|
||||
- Target **1.0s – 5.5s** for a ~300-token reasoning + tool-call turn.
|
||||
|
||||
### 3. One-Shot Live Verification Command (For Future Checks)
|
||||
Any agent (including a fast/lightweight model) can audit all currently available OpenRouter endpoints for `deepseek/deepseek-v4.1-flash-1017` in one step by running:
|
||||
|
||||
```bash
|
||||
# 1. Inspect current endpoint metadata, pricing, cache_read, and 24h uptime:
|
||||
curl -s https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints | \
|
||||
jq -r '.data.endpoints[] | "\(.provider_name) | tag=\(.tag) | quant=\(.quantization) | ctx=\(.context_length) | prompt=\(.pricing.prompt) | comp=\(.pricing.completion) | cache=\(.pricing.input_cache_read) | uptime1d=\(.uptime_last_1d)"'
|
||||
|
||||
# 2. Live-test candidate providers inside openclaw-0 with real nested tool_call payloads:
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c '
|
||||
import os, json, time, urllib.request
|
||||
api_key = os.environ["OPENROUTER_API_KEY"]
|
||||
tools = [{"type": "function", "function": {"name": "tool_call", "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "args": {"type": "object"}}, "required": ["id", "args"]}}}]
|
||||
candidates = ["deepinfra", "streamlake", "inference-net", "venice", "digitalocean", "novita", "parasail", "cloudflare", "nebius"]
|
||||
for prov in candidates:
|
||||
req = urllib.request.Request(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
data=json.dumps({
|
||||
"model": "deepseek/deepseek-v4.1-flash-1017",
|
||||
"messages": [{"role": "user", "content": "Call tool_call with id=\"mcp:homeassistant:homeassistant__ha_get_state\" and args={\"entity_id\": \"media_player.bad\"}"}],
|
||||
"tools": tools, "tool_choice": "auto", "max_tokens": 250,
|
||||
"provider": {"order": [prov], "allow_fallbacks": False}
|
||||
}).encode(),
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
b = json.loads(r.read().decode())
|
||||
tc = b["choices"][0]["message"].get("tool_calls")
|
||||
args = tc[0]["function"]["arguments"] if tc else "NONE"
|
||||
print(f"PASS | {prov:15s} | {time.time()-t0:4.2f}s | actual={b.get(\"provider\")} | args={args}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"FAIL | {prov:15s} | HTTP {e.code} | {e.read().decode()[:140]}")
|
||||
except Exception as e:
|
||||
print(f"FAIL | {prov:15s} | ERR {e}")
|
||||
'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on Gemini 3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
|
||||
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
|
||||
* **100% Reliable Image Generation**: Correctly routes image generation requests to Nano Banana 2 (`google/gemini-3.1-flash-image`), completely eliminating HTTP 404 generation errors.
|
||||
* **Cohesive Voice & Audio Pipeline**: Clear architectural documentation of the Whisper V3 Turbo and Gemini 3.1 Flash TTS integration for dossiers and podcasts.
|
||||
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$1.20–\$1.80** (85–90% savings) through decoupled heartbeats and nightly compaction.
|
||||
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$0.80–\$1.40** (~90% savings) through DeepSeek V4.1 Flash 1017's `$0.00294/M` cache reads, decoupled heartbeats, and nightly compaction.
|
||||
|
||||
### Negative & Mitigations
|
||||
* **OpenRouter Provider Variability**: GLM-5.3-Flash prefill throughput varies across OpenRouter hosting providers.
|
||||
* *Mitigation*: Daily compaction bounds transcript size; failover to DeepSeek V4.1-Flash is available at zero architectural switching cost.
|
||||
* **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`).
|
||||
|
||||
@@ -966,4 +966,88 @@ This document captures fixed issues, operational quirks, and incident root-cause
|
||||
- **How to Avoid Recurrence**:
|
||||
- Always decouple perception tasks (OCR, image description, speech-to-text) into `tools.media` or `imageModel`, keeping primary conversational models focused on reasoning and dialogue without changing the core chat persona.
|
||||
|
||||
---
|
||||
|
||||
### Issue 30: GLM-5.3-Flash Malformed Tool Call Stream Aborts (`streamlake/fp8`), Cold HDD Prompt Assembly Stall, and Fleet-Wide Migration to DeepSeek V4.1 Flash
|
||||
|
||||
- **Date**: 2026-10-17
|
||||
- **Severity**: High (Topic 164 `#Grabengasse & Media` failed with `Provider returned an incomplete or malformed tool call`; 12 total occurrences across 5 agents in 17 days; 504.5s cold-disk event-loop stall on first morning request)
|
||||
- **Symptom**:
|
||||
1. At 07:11:02 CEST, Moritz sent a voice note in Topic 164 (`#Grabengasse & Media`, agent `grabengasse_media`): *"Kannst du mir auf dem Sonos in der Küche die Bibi Blocksberg Playlist Der Affe ist los abspielen?"*
|
||||
2. The turn stalled for **8 minutes and 24 seconds** (`504,493 ms`) during `system-prompt` assembly (`cpuCoreRatio=0.005`, pure disk I/O wait) before sending the first LLM request at 07:19:26 CEST.
|
||||
3. In Turn 1 (`SEQ 175`), `z-ai/glm-5.3-flash` called `tool_search({"query": "sonos spotify play"})`. In Turn 2 (`SEQ 177`), it called `tool_describe({"name": "mcp:spotify:spotify__search"})`.
|
||||
4. In Turn 3 (`SEQ 179`, `responseId: gen-1792214444-T46fmmBLqgmCCe0YJp4X`), after generating 326 reasoning tokens (`"Search for 'Bibi Blocksberg Der Affe ist los' using mcp:spotify:spotify__search"`), the upstream OpenRouter provider (`streamlake/fp8`) aborted the stream mid-JSON while serializing nested `tool_call({"id": "mcp:spotify:spotify__search", "args": {...}})` arguments:
|
||||
`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`.
|
||||
5. OpenClaw only retries or triggers model fallbacks on HTTP status errors (`429`, `502`, `503`, `504`) prior to stream start—not when an HTTP `200 OK` SSE stream terminates mid-generation with a malformed tool-call chunk.
|
||||
- **Root Cause & Fleet Forensics**:
|
||||
1. **`z-ai/glm-5.3-flash` Nested Tool-Call Fragility on `streamlake/fp8` & `siliconflow/fp8`**:
|
||||
- Across October 1–17, 2026, `Provider returned an incomplete or malformed tool call` occurred **12 times** across `main` (6×), `grabengasse_media` (3×), `koch` (1×), `hebamme` (1×), and `doktor` (1×)—exclusively on `z-ai/glm-5.3-flash`, always right after `tool_describe` when emitting nested `tool_call` JSON payloads.
|
||||
2. **Suboptimal OpenRouter Provider Routing**:
|
||||
- `extra_body.provider.order` for `z-ai/glm-5.3-flash` listed `"cloudflare"` first (which does not host `glm-5.3-flash` on OpenRouter), falling through to `streamlake/fp8` (91.7% uptime) and `siliconflow/fp8` (74.1% uptime), and did not block `fp4` (`nvfp4`) quantizations (`phala`, `atlas-cloud`, `parasail`).
|
||||
3. **Fleet-Wide Cost & Cache-Hit Economics (`DeepSeek V4.1 Flash` vs. `GLM 5.3 Flash`)**:
|
||||
- Over the 17-day period (Oct 1–17, 2026), 73% to 94% of all tokens across interactive agents (`21.93M` `cacheRead` vs. `8.94M` uncached input) and background heartbeats (`22.86M` `cacheRead` vs. `7.73M` uncached input) were prompt cache reads.
|
||||
- While `deepseek/deepseek-v4.1-flash` has a slightly higher base uncached prompt rate (`$0.14–$0.15/M` vs. `$0.06–$0.075/M`), its top `fp8` providers (`deepinfra` and `streamlake`, both 100% uptime and full `tool_choice` support) charge **3.5× to 10× less for `cacheRead` tokens** (`$0.0042/M` on DeepInfra, `$0.0030/M` on StreamLake vs. `$0.015–$0.030/M` on GLM 5.3 Flash).
|
||||
- Consequently, running the entire fleet (interactive agents + background heartbeats) on `deepseek/deepseek-v4.1-flash` via `deepinfra`/`streamlake` costs **~$2.69 over 17 days (~$4.75/month)** vs. **$4.50 actual on `GLM 5.3 Flash` (~$7.95/month)**—a **~40% net cost reduction** alongside 0 malformed tool calls.
|
||||
4. **Cold Rotational HDD Read Stall After Nightly Backups**:
|
||||
- Nightly backups (`03:30`) and compaction (`04:45`) evicted the Knowledge Wiki and agent workspace `*.md` files from the Linux kernel page cache, causing the first morning turn's synchronous `system-prompt` loader to block on rotational SATA HDD seeks for 504.5s.
|
||||
- **Resolution**:
|
||||
1. **Fleet-Wide Migration to `openrouter/deepseek/deepseek-v4.1-flash-1017` (with `deepseek-v4.1-flash` 0910 as first fallback)**:
|
||||
- Switched `agents.defaults.model.primary`, `utilityModel`, `heartbeat.model`, `subagents.model`, `memory-core.config.dreaming.model`, and all interactive agent entries (`main`, `fitness`, `koch`, `hebamme`, `travel`, `shopping`, `regensburg`, `recherche`, `doktor`, `itguy`, `grabengasse_media`, `mila_tracker`) to the newly released October 17, 2026 snapshot `openrouter/deepseek/deepseek-v4.1-flash-1017` (Artificial Analysis Intelligence Index 39.5 vs. 24.7 on 0910, and 30% cheaper on DeepInfra at `$0.098/M` prompt, `$0.294/M` completion, `$0.00294/M` `cacheRead`), with `openrouter/deepseek/deepseek-v4.1-flash`, `openrouter/z-ai/glm-5.3-flash`, and `openrouter/qwen/qwen3.8-flash` as fallbacks (`nutrition_tracker` remains on `openrouter/google/gemini-3.8-flash`).
|
||||
2. **Preserved Decoupled Vision Perception (Issue 29 Compatibility)**:
|
||||
- Registered both `deepseek/deepseek-v4.1-flash-1017` and `deepseek/deepseek-v4.1-flash` with `input: ["text"]` under `models.providers.openrouter.models` in `openclaw-instance.secret.yaml` so OpenClaw continues routing all image attachments through `tools.media.image` (`google/gemini-3.8-flash`) for OCR before passing enriched text to DeepSeek V4.1 Flash.
|
||||
3. **Hardened OpenRouter Provider & Quantization Routing (`extra_body.provider`)**:
|
||||
- Configured `openrouter/deepseek/deepseek-v4.1-flash-1017` with `order: ["deepinfra", "inference-net", "parasail", "cloudflare"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]` (live routing tests showed `morph/fp8` is rejected by OpenRouter's `Filter by Tool Compatibility` when `tools` are passed, `deepseek` is blocked by account training-opt-out guardrails, and `baseten` returns HTTP 429).
|
||||
- Configured fallback `openrouter/deepseek/deepseek-v4.1-flash` (`0910`) with `order: ["deepinfra", "streamlake", "inference-net", "digitalocean", "venice", "nebius"]` and `quantizations: ["fp8", "bf16", "unknown"]`.
|
||||
- Cleaned up `openrouter/z-ai/glm-5.3-flash` fallback routing (`order: ["morph", "near-ai", "digitalocean", "venice", "streamlake"]`, `quantizations: ["fp8", "bf16", "unknown"]`, blocking `fp4`).
|
||||
4. **Direct MCP Tool Fastpaths & 1-Turn Sonos Playbook for `grabengasse_media` (Topic 164)**:
|
||||
- Added exact MCP tool IDs (`mcp:spotify:spotify__search`, `mcp:spotify:spotify__play`, `mcp:spotify:spotify__transfer_playback`, `mcp:jelly:jelly__jelly_search`, `mcp:jelly:jelly__jelly_request`, `mcp:homeassistant:homeassistant__ha_get_state`, `mcp:homeassistant:homeassistant__ha_call_service`) as well as all verified Sonos entity IDs (`media_player.bad`, `media_player.wohnzimmer`, `media_player.kuche_sonos`, `media_player.kuche_ikea`, `media_player.dining_room`) and Sonos `source_list` favorites (`"Fusion 2026 Sets"`, `"ChilloutTechno"`, `"deephouse"`, `"OberDITSCHI"`, etc.) directly to `openclaw-instance.secret.yaml`, `knowledge-wiki/agents/grabengasse_media.md`, and `/home/openclaw/.openclaw/workspace-grabengasse_media/AGENTS.md`.
|
||||
- Eliminates both the 2 MCP discovery turns (`tool_search` + `tool_describe`) and the 2 file-read turns (`read("USER.md")`, `read("MEMORY.md")`, `read("SKILL.md")`).
|
||||
5. **Extended Linux Page-Cache Pre-Warming to Knowledge Wiki & Workspace Markdown Files**:
|
||||
- Updated `k8s/openclaw/scripts/gateway-preflight.mjs` and `k8s/openclaw/scripts/compact_sessions.py` (which now also runs `prewarm_page_cache()` at the end of the 04:45 nightly run) to pre-read all Knowledge Wiki (`*.md`, `*.jsonl`) and `workspace*/` `*.md` files into RAM, preventing morning `system-prompt` disk freezes.
|
||||
6. **SQLite `session_nodes` Trigger (`entry_valid`) & Subagent Vector Re-Indexing Fix (`extraPaths: []`)**:
|
||||
- Discovered that SQLite `session_nodes` has an `AFTER UPDATE OF entry_json` trigger (`session_nodes_entry_valid_after_entry_update`) that resets `entry_valid = 0` whenever `entry_json` is modified, throwing `SessionCanonicalKeyMigrationRequiredError` until `UPDATE session_nodes SET entry_valid = 1` is executed.
|
||||
- Hardened `k8s/openclaw/scripts/recover_sessions.py` to always enforce `PRAGMA foreign_keys = ON` before deleting trailing error turns, set `entry_valid = 1` across all rows, and reset `attempts = 0` on pending `channel_ingress_events` in `state/openclaw.sqlite`.
|
||||
- Explicitly set `memory.search.extraPaths: []` at the top level in `openclaw-instance.secret.yaml` and `dedup_subagent_vectors.py` so `mergeMode: merge` does not leave `extraPaths` active globally in `openclaw.json`, preventing subagents from re-embedding 4,059 wiki chunks on their first turn after startup.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Always enforce `quantizations: ["fp8", "bf16"]` in OpenRouter `extra_body.provider` for tool-calling agents so OpenRouter never routes to degraded `fp4` (`nvfp4`) endpoints.
|
||||
- Whenever updating `entry_json` in `session_nodes` via SQLite, always follow up with `UPDATE session_nodes SET entry_valid = 1` in a separate statement (after the `AFTER UPDATE OF entry_json` trigger fires), and always set `PRAGMA foreign_keys = ON` before deleting from `transcript_events`.
|
||||
- Keep Knowledge Wiki and workspace Markdown files in the nightly `prewarm_page_cache()` pass so morning turns hit RAM (`<50ms`) instead of cold rotational disks.
|
||||
|
||||
---
|
||||
|
||||
### Issue 31: DeepSeek V4.1 Flash Tool-Call Truncation (8,192 Default Token Limit), Provider Re-Ranking, and Fireworks Addition
|
||||
|
||||
- **Date**: 2026-10-18
|
||||
- **Severity**: High (Topic 116 `#Regensburg`, agent `regensburg`, failed with `Provider returned an incomplete or malformed tool call` and wedged in status `failed`)
|
||||
- **Symptom**:
|
||||
1. Moritz sent an audio message to Topic 116 (`#Regensburg`): *"Ach ja, auf der Website soll dann quasi, also die Karte soll so aussehen, dass quasi die Anzahl an Sternen da steht, direkt auf der Karte... wo sind die besten Dönerladen in der City..."*
|
||||
2. The agent prepared the dataset `/tmp/doner_web.json` (45 places, 21 KB) and then invoked `openrouter/deepseek/deepseek-v4.1-flash-1017` to construct the single-page interactive Leaflet map application.
|
||||
3. In Turn 160 (`responseId: gen-1792313455-dX7teZlPrKNOMq1BeMCe`), after 3,376 reasoning tokens (`reasoningTokens`), the model streamed the tool call (`exec`) with the HTML/JS website.
|
||||
4. At exactly **8,192 output tokens** (`output: 8192`), the stream was abruptly cut off by OpenRouter/Cloudflare (`finish_reason: "length"`).
|
||||
5. Because the truncation occurred mid-stream inside the tool-call argument JSON payload, OpenClaw threw:
|
||||
`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`.
|
||||
6. The agent sent `⚠️ openrouter/deepseek/deepseek-v4.1-flash-1017 request failed.` to Telegram and remained stuck in `status: "failed"`.
|
||||
- **Root Cause**:
|
||||
1. **Missing `maxTokens` / `max_tokens` Configuration**:
|
||||
- `models.providers.openrouter.models` registered `deepseek/deepseek-v4.1-flash-1017` without `maxTokens`.
|
||||
- When `options.maxTokens` is unset, OpenClaw omits `max_tokens` from `/chat/completions`.
|
||||
- OpenRouter / Cloudflare defaults to **8,192 tokens** when `max_tokens` is omitted.
|
||||
2. **Reasoning Token Competition**:
|
||||
- Unlike standard chat models, DeepSeek V4.1 Flash emits 2,000–3,500 reasoning tokens per complex turn, which count directly against the 8,192 completion limit. This left only ~4,800 tokens for the generated code, causing truncation on large single-page web applications.
|
||||
3. **Suboptimal Provider Ordering in ADR 0002**:
|
||||
- `order: ["deepinfra", "inference-net", "parasail", "cloudflare"]` had Cloudflare in 4th place behind Parasail.
|
||||
- Cloudflare offers `$0.10/M` prompt, `$0.60/M` completion, and `$0.003/M` `cacheRead` (3× cheaper than Parasail), with a **943,718** completion token limit.
|
||||
- **Resolution**:
|
||||
1. **Configured 64k Completion Headroom (`maxTokens: 65536`)**:
|
||||
- Added `maxTokens: 65536` and `contextWindow: 1048576` under `models.providers.openrouter.models` in `openclaw-instance.secret.yaml`.
|
||||
- Added `max_tokens: 65536` to both `params` and `params.extra_body` for `deepseek/deepseek-v4.1-flash-1017` and `deepseek/deepseek-v4.1-flash`.
|
||||
- Applied the change immediately to `/home/openclaw/.openclaw/openclaw.json` on `openclaw-0`.
|
||||
2. **Promoted Cloudflare (#2) and Onboarded Fireworks (#4)**:
|
||||
- Re-ranked provider order to:
|
||||
`order: ["deepinfra", "cloudflare", "inference-net", "fireworks", "parasail"]`
|
||||
- Promoted Cloudflare to #2 for its 97% cache discount and 943k token ceiling.
|
||||
- Added Fireworks as #4 for enterprise tier-1 stability (100% uptime, 943k completion ceiling).
|
||||
3. **Un-wedged Regensburg Session**:
|
||||
- Reset `agent:regensburg:telegram:group:-1004326921510:topic:116` in SQLite from `failed` to `done` and updated `entry_valid = 1`.
|
||||
- **How to Avoid Recurrence**:
|
||||
- For all reasoning-enabled models (DeepSeek V4.1, Gemini 3, etc.), always explicitly configure `maxTokens: 65536` so chain-of-thought tokens never starve downstream tool call payloads.
|
||||
|
||||
+628
-902
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ jobs:
|
||||
expr: "30 3 * * *"
|
||||
tz: "Europe/Berlin"
|
||||
agent: "fitness"
|
||||
model: "openrouter/deepseek/deepseek-v4.1-flash"
|
||||
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
|
||||
session: "isolated"
|
||||
channel: "telegram"
|
||||
to: "-1004326921510"
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
expr: "0 3 * * 0"
|
||||
tz: "Europe/Berlin"
|
||||
agent: "itguy"
|
||||
model: "openrouter/deepseek/deepseek-v4.1-flash"
|
||||
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
|
||||
session: "isolated"
|
||||
channel: "telegram"
|
||||
to: "-1004326921510"
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
expr: "30 6 * * 5"
|
||||
tz: "Europe/Berlin"
|
||||
agent: "recherche"
|
||||
model: "openrouter/deepseek/deepseek-v4.1-flash"
|
||||
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
|
||||
session: "isolated"
|
||||
channel: "telegram"
|
||||
to: "-1004326921510"
|
||||
|
||||
Binary file not shown.
@@ -41,11 +41,12 @@ TIER_A_TARGETS = [
|
||||
]
|
||||
|
||||
def prewarm_page_cache():
|
||||
print("=== Pre-warming SQLite databases into OS 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",
|
||||
"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
|
||||
)
|
||||
@@ -329,6 +330,7 @@ def main():
|
||||
compact_active_sessions()
|
||||
checkpoint_wal_journals()
|
||||
vacuum_and_optimize()
|
||||
prewarm_page_cache()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
import glob, os, sqlite3, sys
|
||||
#!/usr/bin/env python3
|
||||
import glob, json, os, sqlite3, sys
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync, spawn } from "node:child_process";
|
||||
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";
|
||||
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");
|
||||
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...");
|
||||
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");
|
||||
console.log(`[gateway-preflight] Pre-warmed SQLite databases 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.`);
|
||||
// 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.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ def triage_agent_session(agent, topic_id=None, limit=10, db_root="/home/node/.op
|
||||
|
||||
events.reverse()
|
||||
print("\n📜 RECENT TRANSCRIPT EVENTS (Chronological):")
|
||||
|
||||
|
||||
last_role = None
|
||||
for seq, ca_ms, ej_str in events:
|
||||
try:
|
||||
|
||||
@@ -1,80 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
import glob, sqlite3, json, os, sys, time
|
||||
#!/usr/bin/env python3
|
||||
import glob, sqlite3, json, os, sys, time
|
||||
|
||||
t0 = time.time()
|
||||
recovered = 0
|
||||
interrupted_turns = []
|
||||
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 "")
|
||||
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 FROM session_nodes WHERE status = \"running\"")
|
||||
rows = cursor.fetchall()
|
||||
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
|
||||
for sk, ej, csid 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 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))
|
||||
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_windows SET status = \"done\" WHERE status = \"running\"")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
|
||||
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)
|
||||
|
||||
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)
|
||||
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)")
|
||||
|
||||
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
|
||||
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
|
||||
|
||||
if not os.path.exists(INTERRUPTED_FILE):
|
||||
sys.exit(0)
|
||||
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)
|
||||
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)
|
||||
if not turns:
|
||||
sys.exit(0)
|
||||
|
||||
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
# 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
|
||||
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 ""
|
||||
|
||||
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)
|
||||
channel = ctx.get("channel") or "telegram"
|
||||
to_target = ctx.get("to") or ""
|
||||
thread_id = ctx.get("threadId")
|
||||
|
||||
# 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)
|
||||
target = to_target
|
||||
if target.startswith("telegram:"):
|
||||
parts = target.split(":")
|
||||
target = parts[1] if len(parts) > 1 else target
|
||||
|
||||
try:
|
||||
os.remove(INTERRUPTED_FILE)
|
||||
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
@@ -54,7 +54,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--agent "fitness" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
|
||||
--session "isolated" \
|
||||
--channel "telegram" \
|
||||
--to "$TELEGRAM_GROUP" \
|
||||
|
||||
@@ -48,7 +48,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--agent "itguy" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
|
||||
--session "isolated" \
|
||||
--channel "telegram" \
|
||||
--to "$TELEGRAM_GROUP" \
|
||||
|
||||
+1
-1
Submodule knowledge-wiki updated: 419b006574...720c5cf74b
@@ -734,44 +734,93 @@ class DuckDBEngine:
|
||||
today_wet_count = int(diaper_agg[1]) if diaper_agg and diaper_agg[1] is not None else 0
|
||||
today_dirty_count = int(diaper_agg[2]) if diaper_agg and diaper_agg[2] is not None else 0
|
||||
|
||||
# 5. 7d and 30d sleep averages
|
||||
# 5. 7d and 30d sleep averages (use completed days < today_str when available)
|
||||
avg_7 = self.con.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2),
|
||||
ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
|
||||
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count
|
||||
FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
FROM (
|
||||
SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count
|
||||
FROM v_sleep_daily
|
||||
WHERE subject = 'mila' AND log_date < '{today_str}'
|
||||
ORDER BY log_date DESC LIMIT 7
|
||||
)
|
||||
"""
|
||||
).fetchone()
|
||||
if not avg_7 or avg_7[0] is None:
|
||||
avg_7 = self.con.execute(
|
||||
"""
|
||||
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2),
|
||||
ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
|
||||
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count
|
||||
FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
avg_7_tot = f"{avg_7[0]:.2f} h" if avg_7 and avg_7[0] is not None else "—"
|
||||
avg_7_night = f"{avg_7[1]:.2f} h" if avg_7 and avg_7[1] is not None else "—"
|
||||
avg_7_naps = f"{avg_7[3]} Naps ({avg_7[2]:.2f} h)" if avg_7 and avg_7[3] is not None else "—"
|
||||
|
||||
avg_30 = self.con.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT ROUND(AVG(total_sleep_hours), 2)
|
||||
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
|
||||
FROM (
|
||||
SELECT total_sleep_hours FROM v_sleep_daily
|
||||
WHERE subject = 'mila' AND log_date < '{today_str}'
|
||||
ORDER BY log_date DESC LIMIT 30
|
||||
)
|
||||
"""
|
||||
).fetchone()
|
||||
if not avg_30 or avg_30[0] is None:
|
||||
avg_30 = self.con.execute(
|
||||
"""
|
||||
SELECT ROUND(AVG(total_sleep_hours), 2)
|
||||
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
|
||||
"""
|
||||
).fetchone()
|
||||
avg_30_tot = f"{avg_30[0]:.2f} h" if avg_30 and avg_30[0] is not None else "—"
|
||||
|
||||
feed_avg_7 = self.con.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT ROUND(AVG(total_consumed_ml), 0), ROUND(AVG(total_solid_g), 0),
|
||||
ROUND(AVG(total_kcal), 0), ROUND(AVG(bottle_count), 1)
|
||||
FROM (SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count
|
||||
FROM v_feeding_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
FROM (
|
||||
SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count
|
||||
FROM v_feeding_daily
|
||||
WHERE subject = 'mila' AND log_date < '{today_str}'
|
||||
ORDER BY log_date DESC LIMIT 7
|
||||
)
|
||||
"""
|
||||
).fetchone()
|
||||
if not feed_avg_7 or feed_avg_7[0] is None:
|
||||
feed_avg_7 = self.con.execute(
|
||||
"""
|
||||
SELECT ROUND(AVG(total_consumed_ml), 0), ROUND(AVG(total_solid_g), 0),
|
||||
ROUND(AVG(total_kcal), 0), ROUND(AVG(bottle_count), 1)
|
||||
FROM (SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count
|
||||
FROM v_feeding_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
diaper_avg_7 = self.con.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT ROUND(AVG(wet_count), 1), ROUND(AVG(total_changes), 1)
|
||||
FROM (SELECT wet_count, total_changes
|
||||
FROM v_diaper_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
FROM (
|
||||
SELECT wet_count, total_changes
|
||||
FROM v_diaper_daily
|
||||
WHERE subject = 'mila' AND log_date < '{today_str}'
|
||||
ORDER BY log_date DESC LIMIT 7
|
||||
)
|
||||
"""
|
||||
).fetchone()
|
||||
if not diaper_avg_7 or diaper_avg_7[0] is None:
|
||||
diaper_avg_7 = self.con.execute(
|
||||
"""
|
||||
SELECT ROUND(AVG(wet_count), 1), ROUND(AVG(total_changes), 1)
|
||||
FROM (SELECT wet_count, total_changes
|
||||
FROM v_diaper_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
# 6. Past 30 Days History Table & sessions
|
||||
history_rows_data = self.con.execute(
|
||||
@@ -837,10 +886,18 @@ class DuckDBEngine:
|
||||
for r in history_rows_data:
|
||||
d_str = str(r[0])
|
||||
d_obj = datetime.date.fromisoformat(d_str)
|
||||
nh, nm = divmod(r[2] or 0, 60)
|
||||
d_sessions = sessions_by_date.get(d_str, [])
|
||||
d_sessions = list(sessions_by_date.get(d_str, []))
|
||||
row_night_min = int(r[2] or 0)
|
||||
row_nap_min = int(r[4] or 0)
|
||||
row_tot_hours = round(r[1], 2) if r[1] is not None else 0.0
|
||||
|
||||
if d_str == today_str and row_night_min == 0 and today_night_min > 0:
|
||||
row_night_min = today_night_min
|
||||
row_tot_hours = round((row_night_min + row_nap_min) / 60.0, 2)
|
||||
|
||||
nh, nm = divmod(row_night_min, 60)
|
||||
night_durs = [s["duration_minutes"] for s in d_sessions if s["type"] == "night" and s["duration_minutes"]]
|
||||
longest_night = max(night_durs) if night_durs else int(r[2] or 0)
|
||||
longest_night = max(night_durs) if night_durs else row_night_min
|
||||
|
||||
wake_windows = []
|
||||
day_sessions = [s for s in d_sessions if s["type"] == "nap" and s.get("local_start") and s.get("local_end")]
|
||||
@@ -860,12 +917,12 @@ class DuckDBEngine:
|
||||
"weekday": WEEKDAYS_DE[d_obj.weekday()],
|
||||
"formatted_date": d_obj.strftime("%d.%m."),
|
||||
"is_today": (d_str == today_str),
|
||||
"total_hours": round(r[1], 2) if r[1] is not None else 0.0,
|
||||
"night_minutes": int(r[2] or 0),
|
||||
"total_hours": row_tot_hours,
|
||||
"night_minutes": row_night_min,
|
||||
"night_duration_str": f"{nh} h {nm:02d} min" if nh > 0 else f"{nm} min",
|
||||
"nap_minutes": int(r[4] or 0),
|
||||
"nap_minutes": row_nap_min,
|
||||
"nap_count": int(r[3] or 0),
|
||||
"nap_str": f"{r[3]} ({r[4]} min)",
|
||||
"nap_str": f"{r[3]} ({row_nap_min} min)",
|
||||
"longest_night_stretch_min": longest_night,
|
||||
"avg_wake_window_min": avg_wake,
|
||||
"feed_bottles_ml": int(r[5]),
|
||||
@@ -1457,6 +1514,37 @@ class DuckDBEngine:
|
||||
for r in records:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
|
||||
def _parse_event_dt(
|
||||
self,
|
||||
raw: str,
|
||||
now_dt: datetime.datetime,
|
||||
actor: str = "",
|
||||
base_date: Optional[str] = None,
|
||||
) -> datetime.datetime:
|
||||
s = raw.strip()
|
||||
m_time = re.fullmatch(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$", s)
|
||||
if m_time:
|
||||
d_part = base_date or now_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d")
|
||||
hh = int(m_time.group(1))
|
||||
mm = int(m_time.group(2))
|
||||
ss = int(m_time.group(3) or 0)
|
||||
return datetime.datetime.fromisoformat(f"{d_part}T{hh:02d}:{mm:02d}:{ss:02d}").replace(tzinfo=BERLIN_TZ)
|
||||
|
||||
dt = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=BERLIN_TZ)
|
||||
|
||||
if s.endswith("+00:00") or s.endswith("Z"):
|
||||
dt_wall_berlin = dt.replace(tzinfo=BERLIN_TZ)
|
||||
# Guard against LLM agents formatting local Europe/Berlin wall-clock times with +00:00 / Z
|
||||
if (
|
||||
(dt > now_dt + datetime.timedelta(minutes=5) and dt_wall_berlin <= now_dt + datetime.timedelta(minutes=5))
|
||||
or ((actor or "").startswith("agent:") and dt.second == 0 and dt.microsecond == 0)
|
||||
):
|
||||
return dt_wall_berlin
|
||||
|
||||
return dt.astimezone(BERLIN_TZ)
|
||||
|
||||
def add_event(self, req: EventCreateRequest) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
event_id = str(ULID())
|
||||
@@ -1476,9 +1564,7 @@ class DuckDBEngine:
|
||||
start_dt = (now_dt - delta).astimezone(BERLIN_TZ)
|
||||
start_iso = start_dt.isoformat()
|
||||
else:
|
||||
start_dt = datetime.datetime.fromisoformat(req.start)
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
|
||||
start_dt = self._parse_event_dt(req.start, now_dt, actor=req.actor, base_date=req.date)
|
||||
start_iso = start_dt.isoformat()
|
||||
elif req.since:
|
||||
delta = parse_duration(req.since)
|
||||
@@ -1488,21 +1574,48 @@ class DuckDBEngine:
|
||||
start_dt = now_dt.astimezone(BERLIN_TZ)
|
||||
start_iso = start_dt.isoformat()
|
||||
else:
|
||||
start_dt = datetime.datetime.fromisoformat(req.start)
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
|
||||
start_dt = self._parse_event_dt(req.start, now_dt, actor=req.actor)
|
||||
start_iso = start_dt.isoformat()
|
||||
|
||||
end_iso = None
|
||||
end_dt = None
|
||||
if req.end:
|
||||
end_dt = datetime.datetime.fromisoformat(req.end)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
|
||||
end_dt = self._parse_event_dt(
|
||||
req.end,
|
||||
now_dt,
|
||||
actor=req.actor,
|
||||
base_date=start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d"),
|
||||
)
|
||||
if end_dt < start_dt:
|
||||
raise ValueError(f"End time {req.end} cannot be earlier than start time {req.start}")
|
||||
end_iso = end_dt.isoformat()
|
||||
|
||||
# Populate Mila standard recipe defaults when omitted by tracking agents
|
||||
if req.subject == "mila":
|
||||
if req.kind == "feeding.solid" or (req.kind == "nutrition.intake" and "brei" in str(req.payload.get("item") or req.payload.get("type") or "").lower()):
|
||||
if req.kind == "nutrition.intake":
|
||||
req.kind = "feeding.solid"
|
||||
has_grams = any(req.payload.get(k) not in (None, 0) for k in ("amount_g", "consumed_g", "qty"))
|
||||
if not has_grams:
|
||||
item_l = str(req.payload.get("item") or req.meal or "").lower()
|
||||
if any(w in item_l for w in ("abend", "nachmittag", "dinner")):
|
||||
req.payload["amount_g"] = 200
|
||||
req.payload.setdefault("kcal", 110)
|
||||
else:
|
||||
req.payload["amount_g"] = 190
|
||||
req.payload.setdefault("kcal", 135)
|
||||
elif req.kind == "feeding.bottle":
|
||||
c_ml = req.payload.get("consumed_ml")
|
||||
a_ml = req.payload.get("amount_ml")
|
||||
l_ml = req.payload.get("left_ml")
|
||||
if (c_ml is None or c_ml == 0) and (a_ml is None or a_ml == 0):
|
||||
if l_ml is not None:
|
||||
req.payload["amount_ml"] = 270
|
||||
req.payload["consumed_ml"] = max(0, 270 - int(l_ml))
|
||||
else:
|
||||
req.payload["amount_ml"] = 270
|
||||
req.payload["consumed_ml"] = 270
|
||||
|
||||
# 15-Minute Duplicate Guard
|
||||
if not req.allow_duplicate:
|
||||
if req.kind in ("nutrition.intake", "feeding.bottle", "feeding.solid"):
|
||||
@@ -1722,9 +1835,7 @@ class DuckDBEngine:
|
||||
delta = parse_duration(req.since)
|
||||
end_dt = (now_dt - delta).astimezone(BERLIN_TZ)
|
||||
elif req.end_time:
|
||||
end_dt = datetime.datetime.fromisoformat(req.end_time)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
|
||||
end_dt = self._parse_event_dt(req.end_time, now_dt, actor=req.actor)
|
||||
else:
|
||||
end_dt = now_dt.astimezone(BERLIN_TZ)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class GitSyncManager:
|
||||
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").is_dir():
|
||||
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:
|
||||
@@ -64,7 +64,7 @@ class GitSyncManager:
|
||||
"""Ensures repository exists and is initialized."""
|
||||
with self._lock:
|
||||
self._configure_git_client()
|
||||
if (self.wiki_dir / ".git").is_dir():
|
||||
if (self.wiki_dir / ".git").exists():
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
|
||||
@@ -144,7 +144,7 @@ class GitSyncManager:
|
||||
|
||||
def pull_rebase(self) -> bool:
|
||||
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
|
||||
if not (self.wiki_dir / ".git").is_dir():
|
||||
if not (self.wiki_dir / ".git").exists():
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
@@ -208,7 +208,7 @@ class GitSyncManager:
|
||||
|
||||
def commit_and_push(self, commit_msg: str) -> bool:
|
||||
"""Stages data/, commits with descriptive message, rebases and pushes."""
|
||||
if not (self.wiki_dir / ".git").is_dir():
|
||||
if not (self.wiki_dir / ".git").exists():
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
|
||||
@@ -21,23 +21,26 @@ logger = logging.getLogger("duckdb_backend")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Initializing DuckDB Backend...")
|
||||
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
|
||||
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
|
||||
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.")
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down DuckDB Backend...")
|
||||
await git_sync.stop_periodic_sync()
|
||||
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(
|
||||
|
||||
@@ -13,13 +13,14 @@ from app.main import app
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_data_dir():
|
||||
# Use real wiki data dir if exists, else temporary copy
|
||||
temp_root = Path(tempfile.mkdtemp())
|
||||
temp_data = temp_root / "data"
|
||||
if DATA_DIR.is_dir():
|
||||
yield DATA_DIR
|
||||
shutil.copytree(DATA_DIR, temp_data)
|
||||
else:
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
temp_data.mkdir(parents=True, exist_ok=True)
|
||||
yield temp_data
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -127,3 +127,50 @@ def test_duplicate_open_sleep_api_endpoint(client: TestClient):
|
||||
"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,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
@@ -178,7 +178,16 @@ const liveCounterStr = computed(() => {
|
||||
// Wake Window status evaluation (sweet spot 135 to 180 min, i.e. 2h15m to 3h00m)
|
||||
const wakeWindowStatus = computed(() => {
|
||||
if (!data.value || data.value.live_status.is_sleeping) return null
|
||||
const mins = data.value.live_status.wake_window_minutes ?? 0
|
||||
const ls = data.value.live_status
|
||||
let mins = ls.wake_window_minutes ?? 0
|
||||
if (ls.last_wake_iso) {
|
||||
try {
|
||||
const wakeTime = new Date(ls.last_wake_iso).getTime()
|
||||
if (!isNaN(wakeTime)) {
|
||||
mins = Math.max(0, Math.floor((nowEpoch.value - wakeTime) / 60000))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (mins < 135) {
|
||||
return {
|
||||
text: 'Entspannt wach',
|
||||
@@ -255,8 +264,21 @@ const lastFeedDetail = computed(() => {
|
||||
const diaperStatus = computed(() => {
|
||||
if (!data.value) return null
|
||||
const ls = data.value.live_status
|
||||
const elapsed = ls.last_diaper_since_minutes ?? (ls as any).last_diaper_elapsed_minutes
|
||||
const elapsedStool = (ls as any).last_stool_elapsed_minutes
|
||||
let elapsed = ls.last_diaper_since_minutes ?? (ls as any).last_diaper_elapsed_minutes
|
||||
let elapsedStool = (ls as any).last_stool_elapsed_minutes
|
||||
|
||||
if ((ls as any).last_diaper_iso) {
|
||||
try {
|
||||
const t = new Date((ls as any).last_diaper_iso).getTime()
|
||||
if (!isNaN(t)) elapsed = Math.max(0, Math.floor((nowEpoch.value - t) / 60000))
|
||||
} catch {}
|
||||
}
|
||||
if ((ls as any).last_stool_iso) {
|
||||
try {
|
||||
const t = new Date((ls as any).last_stool_iso).getTime()
|
||||
if (!isNaN(t)) elapsedStool = Math.max(0, Math.floor((nowEpoch.value - t) / 60000))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const formatElapsed = (mins: number | null | undefined) => {
|
||||
if (mins === null || mins === undefined) return null
|
||||
@@ -549,6 +571,50 @@ const currentAgeMonths = computed(() => {
|
||||
</button>
|
||||
|
||||
<div v-show="isTodayTimelineOpen" class="space-y-4 pt-2 border-t border-zinc-800/60">
|
||||
<!-- Night Sleep Breakdown (if started today) -->
|
||||
<div v-if="data.today.nights && data.today.nights.length > 0" class="space-y-2">
|
||||
<div class="text-xs font-bold text-indigo-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<span>🌙</span>
|
||||
<span>Nachtschlaf ({{ data.today.nights.length }} erfasst)</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(ns, idx) in data.today.nights"
|
||||
:key="ns.id || idx"
|
||||
class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60 hover:border-indigo-500/30 transition text-xs sm:text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-6 h-6 rounded-lg bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400 font-mono text-xs font-bold">
|
||||
🌙
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-zinc-200 flex items-center gap-2">
|
||||
<span>{{ ns.start }} – {{ ns.end || 'schläft gerade' }} Uhr</span>
|
||||
<span v-if="ns.is_open" class="w-2 h-2 rounded-full bg-indigo-400 animate-pulse"></span>
|
||||
</div>
|
||||
<div class="text-[11px] text-zinc-400">
|
||||
<span v-if="ns.location">({{ ns.location }})</span>
|
||||
<span v-if="ns.note"> · {{ ns.note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-indigo-400 font-mono">
|
||||
{{ ns.duration_minutes ? `${ns.duration_minutes} min` : 'in Ausführung' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="ns.id"
|
||||
@click="handleDeleteItem(ns.id)"
|
||||
title="Nachtschlaf löschen"
|
||||
class="p-1 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Naps Breakdown -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
|
||||
@@ -57,7 +57,10 @@ const bars = computed(() => {
|
||||
let nightHours = 0
|
||||
let napHours = 0
|
||||
|
||||
if (day.sessions && day.sessions.length > 0) {
|
||||
if (typeof day.night_minutes === 'number' || typeof day.nap_minutes === 'number') {
|
||||
nightHours = Math.max(0, (day.night_minutes || 0) / 60)
|
||||
napHours = Math.max(0, (day.nap_minutes || 0) / 60)
|
||||
} else if (day.sessions && day.sessions.length > 0) {
|
||||
for (const s of day.sessions) {
|
||||
const mins = s.duration_minutes || 0
|
||||
if (s.type === 'night') {
|
||||
@@ -71,7 +74,7 @@ const bars = computed(() => {
|
||||
napHours = Math.max(0, day.total_hours - nightHours)
|
||||
}
|
||||
|
||||
const totalHours = Math.max(0, day.total_hours)
|
||||
const totalHours = Math.max(0, day.total_hours, nightHours + napHours)
|
||||
const yTotal = getY(totalHours)
|
||||
const yNight = getY(nightHours)
|
||||
const nightHeight = Math.max(0, (paddingTop + innerHeight) - yNight)
|
||||
@@ -172,6 +175,59 @@ function getMinuteFromTime(timeStr: string): number {
|
||||
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
|
||||
}
|
||||
|
||||
interface RasterBlock {
|
||||
startMin: number
|
||||
durationMin: number
|
||||
type: 'night' | 'nap'
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
function getDay24hBlocks(day: HistoryRow, dayIdx: number): RasterBlock[] {
|
||||
const blocks: RasterBlock[] = []
|
||||
|
||||
// 1. Check previous calendar day (props.history[dayIdx + 1]) for overnight sleep spilling into 00:00..wake_time
|
||||
const prevDay = props.history[dayIdx + 1]
|
||||
if (prevDay?.sessions) {
|
||||
for (const s of prevDay.sessions) {
|
||||
if (!s.start) continue
|
||||
const sMin = getMinuteFromTime(s.start)
|
||||
const dur = s.duration_minutes || 60
|
||||
if (sMin + dur > 1440) {
|
||||
const morningMin = Math.min(1440, (sMin + dur) - 1440)
|
||||
if (morningMin > 0) {
|
||||
blocks.push({
|
||||
startMin: 0,
|
||||
durationMin: morningMin,
|
||||
type: s.type === 'night' ? 'night' : 'nap',
|
||||
label: `${morningMin}m`,
|
||||
title: `Nachtschlaf (Vorabend ${s.start} – ${s.end || 'morgens'}): ${morningMin} min nach 00:00 Uhr (${s.duration_minutes} min gesamt)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add sessions starting on this calendar day (clamped to 24:00)
|
||||
if (day.sessions) {
|
||||
for (const s of day.sessions) {
|
||||
if (!s.start) continue
|
||||
const sMin = getMinuteFromTime(s.start)
|
||||
const dur = s.duration_minutes || 60
|
||||
const clampedDur = Math.max(1, Math.min(dur, 1440 - sMin))
|
||||
blocks.push({
|
||||
startMin: sMin,
|
||||
durationMin: clampedDur,
|
||||
type: s.type === 'night' ? 'night' : 'nap',
|
||||
label: `${clampedDur}m`,
|
||||
title: `${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function handlePointer(event: MouseEvent | TouchEvent) {
|
||||
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
|
||||
@@ -474,7 +530,7 @@ function handlePointer(event: MouseEvent | TouchEvent) {
|
||||
<!-- Raster Bars -->
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="day in rasterDays"
|
||||
v-for="(day, dayIdx) in rasterDays"
|
||||
:key="day.date"
|
||||
class="flex items-center gap-2 group"
|
||||
>
|
||||
@@ -494,24 +550,22 @@ function handlePointer(event: MouseEvent | TouchEvent) {
|
||||
</div>
|
||||
|
||||
<!-- Sessions Blocks -->
|
||||
<template v-if="day.sessions && day.sessions.length > 0">
|
||||
<template v-for="(s, sIdx) in day.sessions" :key="sIdx">
|
||||
<!-- If valid start time -->
|
||||
<div
|
||||
v-if="s.start"
|
||||
:style="{
|
||||
left: `${(getMinuteFromTime(s.start) / 1440) * 100}%`,
|
||||
width: `${Math.max(1.2, ((s.duration_minutes || 60) / 1440) * 100)}%`,
|
||||
}"
|
||||
:class="s.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
|
||||
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
|
||||
:title="`${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`"
|
||||
>
|
||||
<span v-if="(s.duration_minutes || 0) >= 45" class="truncate px-0.5">
|
||||
{{ s.duration_minutes }}m
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="getDay24hBlocks(day, dayIdx).length > 0">
|
||||
<div
|
||||
v-for="(b, bIdx) in getDay24hBlocks(day, dayIdx)"
|
||||
:key="bIdx"
|
||||
:style="{
|
||||
left: `${(b.startMin / 1440) * 100}%`,
|
||||
width: `${Math.max(1.2, (b.durationMin / 1440) * 100)}%`,
|
||||
}"
|
||||
:class="b.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
|
||||
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
|
||||
:title="b.title"
|
||||
>
|
||||
<span v-if="b.durationMin >= 45" class="truncate px-0.5">
|
||||
{{ b.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-[10px] text-zinc-600 italic px-2 py-0.5">Keine Einzelschläfchen</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface HistoryRow {
|
||||
formatted_date: string
|
||||
is_today: boolean
|
||||
total_hours: number
|
||||
night_minutes?: number
|
||||
nap_minutes?: number
|
||||
night_duration_str: string
|
||||
nap_str: string
|
||||
feed_bottles_ml: number
|
||||
|
||||
@@ -376,6 +376,23 @@ resource "authentik_application" "hosting_family" {
|
||||
protocol_provider = authentik_provider_proxy.hosting_family.id
|
||||
}
|
||||
|
||||
# 11. Family Portal (family.haumdaucher.de)
|
||||
resource "authentik_provider_proxy" "family_portal" {
|
||||
name = "Family Portal"
|
||||
external_host = "https://family.haumdaucher.de"
|
||||
mode = "forward_single"
|
||||
authorization_flow = data.authentik_flow.default_authorization_flow.id
|
||||
invalidation_flow = data.authentik_flow.default_invalidation_flow.id
|
||||
intercept_header_auth = true
|
||||
access_token_validity = "days=30"
|
||||
}
|
||||
|
||||
resource "authentik_application" "family_portal" {
|
||||
name = "Family Portal"
|
||||
slug = "family-portal"
|
||||
protocol_provider = authentik_provider_proxy.family_portal.id
|
||||
}
|
||||
|
||||
# --- Outpost: Embedded Proxy Outpost ---
|
||||
|
||||
resource "authentik_outpost" "embedded" {
|
||||
@@ -392,6 +409,7 @@ resource "authentik_outpost" "embedded" {
|
||||
authentik_provider_proxy.n8n.id,
|
||||
authentik_provider_proxy.silverbullet.id,
|
||||
authentik_provider_proxy.hosting_family.id,
|
||||
authentik_provider_proxy.family_portal.id,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -789,6 +807,18 @@ resource "authentik_policy_binding" "hosting_family_family" {
|
||||
order = 1
|
||||
}
|
||||
|
||||
resource "authentik_policy_binding" "family_portal_admin" {
|
||||
target = authentik_application.family_portal.uuid
|
||||
group = authentik_group.infra_admins.id
|
||||
order = 0
|
||||
}
|
||||
|
||||
resource "authentik_policy_binding" "family_portal_family" {
|
||||
target = authentik_application.family_portal.uuid
|
||||
group = authentik_group.family.id
|
||||
order = 1
|
||||
}
|
||||
|
||||
|
||||
# 2. Shared & Member Applications (Accessible to all authenticated users)
|
||||
resource "authentik_policy_binding" "grafana_users" {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.14.3",
|
||||
"serial": 423,
|
||||
"serial": 429,
|
||||
"lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e",
|
||||
"outputs": {
|
||||
"coder_oauth_client_secret": {
|
||||
@@ -217,7 +217,7 @@
|
||||
"id": "7",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": "2026-09-05T14:23:02.674777Z",
|
||||
"last_login": "2026-10-12T06:28:53.399647Z",
|
||||
"name": "Amelie Graf",
|
||||
"path": "users",
|
||||
"pk": 7,
|
||||
@@ -306,6 +306,41 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_application",
|
||||
"name": "family_portal",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"backchannel_providers": null,
|
||||
"group": "",
|
||||
"id": "family-portal",
|
||||
"meta_description": "",
|
||||
"meta_hide": false,
|
||||
"meta_icon": "",
|
||||
"meta_launch_url": "",
|
||||
"meta_publisher": "",
|
||||
"name": "Family Portal",
|
||||
"open_in_new_tab": false,
|
||||
"policy_engine_mode": "any",
|
||||
"protocol_provider": 17,
|
||||
"slug": "family-portal",
|
||||
"uuid": "48cb7294-f186-4fa9-80d8-007b9ebb8f62"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_provider_proxy.family_portal",
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_application",
|
||||
@@ -1155,7 +1190,8 @@
|
||||
3,
|
||||
10,
|
||||
15,
|
||||
16
|
||||
16,
|
||||
17
|
||||
],
|
||||
"service_connection": "",
|
||||
"type": "proxy"
|
||||
@@ -1165,6 +1201,7 @@
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_provider_proxy.alertmanager",
|
||||
"authentik_provider_proxy.family_portal",
|
||||
"authentik_provider_proxy.gitea_phpmyadmin",
|
||||
"authentik_provider_proxy.hass_coder",
|
||||
"authentik_provider_proxy.hosting_family",
|
||||
@@ -1251,6 +1288,73 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_policy_binding",
|
||||
"name": "family_portal_admin",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"enabled": true,
|
||||
"failure_result": false,
|
||||
"group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690",
|
||||
"id": "f9dffd65-0fe7-4384-a2e1-684ff2a85475",
|
||||
"negate": false,
|
||||
"order": 0,
|
||||
"policy": "",
|
||||
"target": "48cb7294-f186-4fa9-80d8-007b9ebb8f62",
|
||||
"timeout": 30,
|
||||
"user": 0
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_application.family_portal",
|
||||
"authentik_group.infra_admins",
|
||||
"authentik_provider_proxy.family_portal",
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_policy_binding",
|
||||
"name": "family_portal_family",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"enabled": true,
|
||||
"failure_result": false,
|
||||
"group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
|
||||
"id": "033b6b44-6a29-4d01-b7f1-750b33070541",
|
||||
"negate": false,
|
||||
"order": 1,
|
||||
"policy": "",
|
||||
"target": "48cb7294-f186-4fa9-80d8-007b9ebb8f62",
|
||||
"timeout": 30,
|
||||
"user": 0
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_application.family_portal",
|
||||
"authentik_group.family",
|
||||
"authentik_provider_proxy.family_portal",
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow",
|
||||
"data.authentik_user.amy"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_policy_binding",
|
||||
@@ -2302,6 +2406,48 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_provider_proxy",
|
||||
"name": "family_portal",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"access_token_validity": "days=30",
|
||||
"authentication_flow": "",
|
||||
"authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42",
|
||||
"basic_auth_enabled": false,
|
||||
"basic_auth_password_attribute": "",
|
||||
"basic_auth_username_attribute": "",
|
||||
"client_id": "4HO1RbjsgDq3JI5K4VeXF3SFCmeCTePXs3466tfz",
|
||||
"cookie_domain": "",
|
||||
"external_host": "https://family.haumdaucher.de",
|
||||
"id": "17",
|
||||
"intercept_header_auth": true,
|
||||
"internal_host": "",
|
||||
"internal_host_ssl_validation": true,
|
||||
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
|
||||
"jwks_sources": null,
|
||||
"jwt_federation_providers": null,
|
||||
"jwt_federation_sources": null,
|
||||
"mode": "forward_single",
|
||||
"name": "Family Portal",
|
||||
"property_mappings": null,
|
||||
"refresh_token_validity": "days=30",
|
||||
"skip_path_regex": ""
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_provider_proxy",
|
||||
@@ -2410,8 +2556,8 @@
|
||||
"internal_host_ssl_validation": true,
|
||||
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
|
||||
"jwks_sources": null,
|
||||
"jwt_federation_providers": null,
|
||||
"jwt_federation_sources": null,
|
||||
"jwt_federation_providers": [],
|
||||
"jwt_federation_sources": [],
|
||||
"mode": "forward_single",
|
||||
"name": "Hosting Family",
|
||||
"property_mappings": null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.14.3",
|
||||
"serial": 417,
|
||||
"serial": 423,
|
||||
"lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e",
|
||||
"outputs": {
|
||||
"coder_oauth_client_secret": {
|
||||
@@ -211,7 +211,8 @@
|
||||
"date_joined": "2026-09-05T14:23:01.528947Z",
|
||||
"email": "",
|
||||
"groups": [
|
||||
"ad33b003-77a4-4571-bec8-8301f6d6552b"
|
||||
"ad33b003-77a4-4571-bec8-8301f6d6552b",
|
||||
"5e91f468-a0f2-4433-aa23-a7b6ad69df38"
|
||||
],
|
||||
"id": "7",
|
||||
"is_active": true,
|
||||
@@ -495,6 +496,41 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_application",
|
||||
"name": "hosting_family",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"backchannel_providers": null,
|
||||
"group": "",
|
||||
"id": "hosting-family",
|
||||
"meta_description": "",
|
||||
"meta_hide": false,
|
||||
"meta_icon": "",
|
||||
"meta_launch_url": "",
|
||||
"meta_publisher": "",
|
||||
"name": "Hosting Family",
|
||||
"open_in_new_tab": false,
|
||||
"policy_engine_mode": "any",
|
||||
"protocol_provider": 16,
|
||||
"slug": "hosting-family",
|
||||
"uuid": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_provider_proxy.hosting_family",
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_application",
|
||||
@@ -1007,8 +1043,8 @@
|
||||
"id": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
|
||||
"is_superuser": false,
|
||||
"name": "family",
|
||||
"parents": null,
|
||||
"roles": null,
|
||||
"parents": [],
|
||||
"roles": [],
|
||||
"users": [
|
||||
7
|
||||
]
|
||||
@@ -1118,7 +1154,8 @@
|
||||
7,
|
||||
3,
|
||||
10,
|
||||
15
|
||||
15,
|
||||
16
|
||||
],
|
||||
"service_connection": "",
|
||||
"type": "proxy"
|
||||
@@ -1130,6 +1167,7 @@
|
||||
"authentik_provider_proxy.alertmanager",
|
||||
"authentik_provider_proxy.gitea_phpmyadmin",
|
||||
"authentik_provider_proxy.hass_coder",
|
||||
"authentik_provider_proxy.hosting_family",
|
||||
"authentik_provider_proxy.n8n",
|
||||
"authentik_provider_proxy.nextcloud_phpmyadmin",
|
||||
"authentik_provider_proxy.openclaw",
|
||||
@@ -1393,6 +1431,73 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_policy_binding",
|
||||
"name": "hosting_family_admin",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"enabled": true,
|
||||
"failure_result": false,
|
||||
"group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690",
|
||||
"id": "55b2f74f-f650-40a2-9781-a00a5978e1e8",
|
||||
"negate": false,
|
||||
"order": 0,
|
||||
"policy": "",
|
||||
"target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3",
|
||||
"timeout": 30,
|
||||
"user": 0
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_application.hosting_family",
|
||||
"authentik_group.infra_admins",
|
||||
"authentik_provider_proxy.hosting_family",
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_policy_binding",
|
||||
"name": "hosting_family_family",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"enabled": true,
|
||||
"failure_result": false,
|
||||
"group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
|
||||
"id": "b598a889-f04b-4955-8a01-6558cfaad60e",
|
||||
"negate": false,
|
||||
"order": 1,
|
||||
"policy": "",
|
||||
"target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3",
|
||||
"timeout": 30,
|
||||
"user": 0
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"authentik_application.hosting_family",
|
||||
"authentik_group.family",
|
||||
"authentik_provider_proxy.hosting_family",
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow",
|
||||
"data.authentik_user.amy"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_policy_binding",
|
||||
@@ -2281,6 +2386,48 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_provider_proxy",
|
||||
"name": "hosting_family",
|
||||
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"access_token_validity": "days=30",
|
||||
"authentication_flow": "",
|
||||
"authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42",
|
||||
"basic_auth_enabled": false,
|
||||
"basic_auth_password_attribute": "",
|
||||
"basic_auth_username_attribute": "",
|
||||
"client_id": "27XovQEtW8nMAgUN6kANZnQZMMbWVEbi8Mh6VsGP",
|
||||
"cookie_domain": "",
|
||||
"external_host": "https://hosting.haumdaucher.de",
|
||||
"id": "16",
|
||||
"intercept_header_auth": true,
|
||||
"internal_host": "",
|
||||
"internal_host_ssl_validation": true,
|
||||
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
|
||||
"jwks_sources": null,
|
||||
"jwt_federation_providers": null,
|
||||
"jwt_federation_sources": null,
|
||||
"mode": "forward_single",
|
||||
"name": "Hosting Family",
|
||||
"property_mappings": null,
|
||||
"refresh_token_validity": "days=30",
|
||||
"skip_path_regex": ""
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"data.authentik_flow.default_authorization_flow",
|
||||
"data.authentik_flow.default_invalidation_flow"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "authentik_provider_proxy",
|
||||
|
||||
Reference in New Issue
Block a user