Compare commits
5 commits
main
...
epimetheus
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d0ae0c8cb | |||
| 6e8aab2dd9 | |||
| b766176259 | |||
| 71b62e6d03 | |||
| eee2b5c78b |
888 changed files with 2370 additions and 303913 deletions
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
name: crabbox
|
||||
description: Use for remote Linux verification, isolated CI proof, Crabbox lease cleanup, and PR evidence without production deploys or secret forwarding.
|
||||
---
|
||||
|
||||
# Crabbox
|
||||
|
||||
Use Crabbox for remote Linux verification and PR proof only.
|
||||
|
||||
Allowed jobs:
|
||||
|
||||
- `crabbox job run unit`
|
||||
- `crabbox job run lint-phase1b`
|
||||
- `crabbox job run ci-contract`
|
||||
- `crabbox job run phase1b-local-proof`
|
||||
- `crabbox job run sync-smoke`
|
||||
|
||||
Default workflow:
|
||||
|
||||
1. Run `crabbox job run --dry-run ci-contract`.
|
||||
2. Run `crabbox job run --dry-run phase1b-local-proof`.
|
||||
3. Inspect the planned commands and confirm no production secrets or production deploy commands appear.
|
||||
4. Run `crabbox job run ci-contract`.
|
||||
5. Run `crabbox job run phase1b-local-proof`.
|
||||
6. Save the run id, lease id, stdout, downloaded proof JSON, and JUnit output.
|
||||
7. Stop the lease unless the CLI has already stopped it.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- Do not run production deploy commands from Crabbox.
|
||||
- Do not forward production GitHub, Forgejo, OpenRouter, SSH, Bitwarden, or VPS secrets.
|
||||
- Do not target the production `decision-engine` repo for sandbox proof.
|
||||
- Do not mutate the production VPS.
|
||||
- Do not call Crabbox proof equivalent to production proof unless the lease recreates `/opt/teleo-eval`, systemd services, runtime users, DB paths, timers, and deploy scripts.
|
||||
|
||||
Failure handling:
|
||||
|
||||
- If sync sanity fails, stop the lease and retry on a fresh lease.
|
||||
- If a proof script fails, save the full run output and do not summarize it as a pass.
|
||||
- If a remote box has unknown state, stop it instead of debugging against reused state.
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
---
|
||||
name: decision-engine-refinement
|
||||
description: Use when improving Living IP decision-engine quality, LLM model selection, evaluator prompts, rubrics, replay evals, Rio or Theseus reviewer behavior, or model bakeoffs.
|
||||
---
|
||||
|
||||
# Decision Engine Refinement
|
||||
|
||||
Use this skill for quality work, not infrastructure work. Pentagon.run or Crabbox can run remote jobs; this repo owns model judgment, rubric design, prompt/tool refinement, and proof artifacts.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `docs/llm-refinement-decision-engine.md`.
|
||||
2. Identify the lane: Rio economics, Theseus model integrity, Leo cross-domain, domain factuality, retrieval quality, or prompt/tool self-upgrade.
|
||||
3. Build or reuse a replayable fixture before changing prompts or model assignments.
|
||||
4. Compare baseline vs candidate with the same input, same rubric, and structured verdict format.
|
||||
5. Record false approves, false rejects, useful disagreements, cost, and latency.
|
||||
6. Change runtime prompts/models only after the candidate shows a measured improvement with no critical regression.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Do not change live model assignments because one answer sounds better.
|
||||
- Do not use production DB writes to tune prompts.
|
||||
- Do not collapse Rio and Theseus into generic "reviewers".
|
||||
- Do not treat payment, popularity, or engagement as quality approval.
|
||||
- Do not claim production decision-engine improvement without replay evidence and live/staging readback.
|
||||
|
||||
## Agent Responsibilities
|
||||
|
||||
- Rio: incentive design, contribution weights, paid-query effects, market/mechanism reasoning, OPSEC, correlated-prior warnings.
|
||||
- Theseus: model diversity, adversarial evals, disagreement queues, self-upgrade criteria, prompt/tool safety, verifier drift.
|
||||
- Leo: cross-domain synthesis, fallback review, final arbitration where the route or rubric is ambiguous.
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- fixture file or DB query used for sampling;
|
||||
- baseline verdict output;
|
||||
- candidate verdict output;
|
||||
- summary JSON with quality, cost, latency, and disagreement metrics;
|
||||
- patch scoped to prompts, model config, rubric docs, or eval harness.
|
||||
|
||||
Run `python3 scripts/check_llm_refinement_contract.py` after editing this surface.
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
---
|
||||
name: leo-telegram-canary-ops
|
||||
description: Use for live Leo Telegram tests through authenticated Chrome/Computer Use, including memory, KB audit, staged DB writes, screenshots, and clear separation from external outreach.
|
||||
---
|
||||
|
||||
# Leo Telegram Canary Ops
|
||||
|
||||
## Job
|
||||
|
||||
Run Telegram-visible Leo canaries when testing Leo itself, with retained screenshots and DB readbacks.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "test Leo in Telegram"
|
||||
- "send the Leo group message"
|
||||
- "Telegram-visible proof"
|
||||
- "Chrome Telegram canary"
|
||||
- "Leo remembers conversation"
|
||||
- "staged write through Telegram"
|
||||
|
||||
## Authorization Boundary
|
||||
|
||||
Live Telegram bot messages to the Leo group are authorized when they are test canaries for Leo behavior.
|
||||
|
||||
This is different from external outreach. Do not send external outreach, partner messages, public announcements, or non-test communications without exact authorization.
|
||||
|
||||
## Required Tooling
|
||||
|
||||
Use Computer Use through `node_repl` and the Chrome/Computer Use skill path. Do not use:
|
||||
|
||||
- AppleScript,
|
||||
- `osascript`,
|
||||
- JXA,
|
||||
- System Events,
|
||||
- focus hijacking,
|
||||
- foregrounding hacks.
|
||||
|
||||
## Canary Types
|
||||
|
||||
Name the canary before sending:
|
||||
|
||||
- bot readiness,
|
||||
- memory,
|
||||
- KB audit truth,
|
||||
- proposed-vs-applied truth,
|
||||
- open-ended m3taversal-style triage,
|
||||
- staged write,
|
||||
- no-canonical-mutation proof,
|
||||
- screenshot/readback proof.
|
||||
|
||||
## Message Discipline
|
||||
|
||||
Use unique markers such as `WL-LIVE-TG-M3TAVERSAL-YYYYMMDD-Tn`.
|
||||
|
||||
Ask Leo for machine-checkable reply markers like:
|
||||
|
||||
- `LIVE_TG_TURN1_ACK`
|
||||
- `LIVE_TG_TURN3_STAGED`
|
||||
- `LIVE_TG_TURN4_READBACK`
|
||||
|
||||
Do not rely only on exact-ID prompts. At least one representative canary should be a vague operator-style prompt such as: m3taversal says the agents are not working, the KB is in the same state as last night, and Leo should be able to manipulate the KB; ask Leo what that means, what it would inspect first, what fixed means, and how it separates proposed, approved, and applied. Keep this canary read-only unless the task explicitly authorizes a staging or apply step.
|
||||
|
||||
## Required Proof
|
||||
|
||||
After each live Telegram canary:
|
||||
|
||||
1. Capture screenshot.
|
||||
2. Record exact message sent.
|
||||
3. Record Leo reply marker and substance.
|
||||
4. Query DB if the canary involves DB state.
|
||||
5. Record canonical public table counts when proving no mutation.
|
||||
6. Save a local markdown/json artifact.
|
||||
7. Sync artifacts to VPS report dir when relevant.
|
||||
|
||||
## Current Known Proof
|
||||
|
||||
- Live memory and KB audit are retained in
|
||||
`docs/reports/leo-working-state-20260709/telegram-live-canary-current.json`.
|
||||
- Live staged-write proof is retained in
|
||||
`docs/reports/leo-working-state-20260709/telegram-live-db-write-canary-20260709.json`.
|
||||
- Open-ended m3taversal-standard read-only triage is retained in
|
||||
`docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-current.md`.
|
||||
|
||||
Historical receipts contain immutable legacy marker IDs. Treat them only as
|
||||
evidence identifiers; never reuse them as a participant name or future marker.
|
||||
|
||||
Use `docs/reports/leo-working-state-20260709/current-truth-index.md` for artifact paths.
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
---
|
||||
name: living-ip-kb-interop
|
||||
description: Use when giving Hermes, OpenClaw, Claude-style, Pentagon, or other external agents safe read/write access patterns for the Living IP knowledge base.
|
||||
---
|
||||
|
||||
# Living IP KB Interop
|
||||
|
||||
Use this skill when an outside agent needs to read from the Living IP knowledge base or propose a write back into it. The default is propose-first, proof-backed, and no-secret.
|
||||
|
||||
## Goal
|
||||
|
||||
Any Hermes, OpenClaw, Claude-style, or Pentagon agent should be able to:
|
||||
|
||||
1. search the knowledge base;
|
||||
2. read a cited file or record;
|
||||
3. propose a source, claim, entity, or correction;
|
||||
4. route the proposal to the right evaluator agents;
|
||||
5. leave a proof artifact that shows inputs, tools, and no denied actions.
|
||||
|
||||
## Read Path
|
||||
|
||||
Prefer deterministic local surfaces before asking an LLM:
|
||||
|
||||
- repository files under the knowledge base checkout;
|
||||
- generated claim indexes from `lib/claim_index.py`;
|
||||
- search helpers in `lib/search.py`;
|
||||
- copied SQLite state through `teleo-db-operator`;
|
||||
- retained proof JSON at a caller-specified generated-output path.
|
||||
|
||||
Read outputs must include file paths, source paths, claim/entity IDs when available, and the exact query used.
|
||||
|
||||
## Write Path
|
||||
|
||||
All writes are proposals until the normal review/evaluation pipeline accepts them.
|
||||
|
||||
Allowed proposal targets:
|
||||
|
||||
- source file proposal;
|
||||
- claim file proposal;
|
||||
- entity file proposal;
|
||||
- correction proposal;
|
||||
- route/evaluator proof artifact.
|
||||
|
||||
Required fields:
|
||||
|
||||
- source or rationale;
|
||||
- target domain;
|
||||
- proposed author/agent;
|
||||
- route evidence;
|
||||
- confidence or uncertainty tag;
|
||||
- citations to existing KB context;
|
||||
- proof output path.
|
||||
|
||||
Do not write directly to main. Do not mutate production `pipeline.db`. Use `teleo-db-operator` for any SQLite write, and only after explicit authorization, backup, transaction, and readback.
|
||||
|
||||
## Minimal Tool Contract
|
||||
|
||||
Adapters should expose this shape even if their runtime uses different names:
|
||||
|
||||
- `kb.search(query, domain?, limit?)`
|
||||
- `kb.get(path_or_id)`
|
||||
- `kb.propose_source(markdown, metadata)`
|
||||
- `kb.propose_claim(markdown, metadata)`
|
||||
- `kb.propose_entity(markdown, metadata)`
|
||||
- `kb.route(diff_or_metadata)`
|
||||
- `kb.proof(path, payload)`
|
||||
|
||||
If a runtime cannot implement one of these, record the missing tool as a blocker instead of silently skipping it.
|
||||
|
||||
## Denied Actions
|
||||
|
||||
- raw Bitwarden export;
|
||||
- card, token, or password reads;
|
||||
- production DB writes;
|
||||
- direct pushes to main;
|
||||
- public comments or messages;
|
||||
- hidden Slack, Linear, Telegram, or GitHub sends;
|
||||
- uncited knowledge writes;
|
||||
- model-driven edits without route evidence.
|
||||
|
||||
## Expected Artifact
|
||||
|
||||
Write `<proof-output>/kb-interop-proof.json` containing:
|
||||
|
||||
- runtime name;
|
||||
- model/provider if known;
|
||||
- tools invoked;
|
||||
- denied tools not invoked;
|
||||
- query or input fixture;
|
||||
- cited reads;
|
||||
- proposed writes;
|
||||
- route evidence;
|
||||
- verifier result.
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
---
|
||||
name: nousresearch-hermes-agent
|
||||
description: Use when packaging Living IP agents, skills, prompts, memory, model routing, or decision-engine workflows for NousResearch Hermes Agent.
|
||||
---
|
||||
|
||||
# NousResearch Hermes Agent
|
||||
|
||||
Use this skill to adapt Living IP decision-engine behavior to Hermes Agent. Keep the package fixture-first and no-secret by default.
|
||||
|
||||
## Current External Surface
|
||||
|
||||
As of 2026-06-01, the upstream Hermes Agent README describes:
|
||||
|
||||
- model switching via `hermes model`;
|
||||
- tools via `hermes tools`;
|
||||
- a messaging gateway for Telegram, Discord, Slack, WhatsApp, Signal, and CLI;
|
||||
- built-in skill creation and self-improvement;
|
||||
- cron scheduling;
|
||||
- terminal backends including local, Docker, SSH, Modal, and Daytona;
|
||||
- OpenClaw migration commands.
|
||||
|
||||
Verify upstream docs before depending on a command in code.
|
||||
|
||||
## Living IP Package Shape
|
||||
|
||||
Create a package that includes:
|
||||
|
||||
- agent identity file for Rio or Theseus;
|
||||
- skill instructions copied from repo-owned `.agents/skills/*`;
|
||||
- `living-ip-kb-interop` for read/propose/writeback behavior;
|
||||
- no-secret tool allowlist;
|
||||
- fixture replay command;
|
||||
- model selection notes;
|
||||
- proof output path.
|
||||
|
||||
Do not package production DBs, tokens, API keys, SSH keys, or Bitwarden exports.
|
||||
|
||||
## Rio Package
|
||||
|
||||
Rio Hermes package should focus on:
|
||||
|
||||
- internet finance and mechanism reasoning;
|
||||
- contribution weights and paid-query effects;
|
||||
- OPSEC finance filters;
|
||||
- source-diversity warnings;
|
||||
- fixture tests for false economic reasoning.
|
||||
|
||||
## Theseus Package
|
||||
|
||||
Theseus Hermes package should focus on:
|
||||
|
||||
- model-diversity evals;
|
||||
- disagreement queues;
|
||||
- self-upgrade criteria;
|
||||
- prompt/tool safety;
|
||||
- fixture tests for overconfident or poorly grounded model judgments.
|
||||
|
||||
## Handoff Contract
|
||||
|
||||
Every Hermes handoff must include:
|
||||
|
||||
1. install/config snippet;
|
||||
2. model/provider selection left configurable;
|
||||
3. tool allowlist;
|
||||
4. fixture-first demo;
|
||||
5. no-live-write default;
|
||||
6. proof artifact path;
|
||||
7. known blockers.
|
||||
|
||||
Do not claim Hermes production integration until a Hermes runtime actually executes the fixture and writes proof.
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
---
|
||||
name: openclaw-agent
|
||||
description: Use when adapting Living IP decision-engine agents, skills, tools, prompt files, or no-secret workflows to OpenClaw agent workspaces.
|
||||
---
|
||||
|
||||
# OpenClaw Agent
|
||||
|
||||
Use this skill to package Living IP decision-engine behavior for OpenClaw workspaces. Treat OpenClaw as a distribution/runtime surface, not a new source of truth.
|
||||
|
||||
## Current External Surface
|
||||
|
||||
As of 2026-06-01, the upstream OpenClaw README describes:
|
||||
|
||||
- Node 24 or Node 22.19+ runtime;
|
||||
- `openclaw onboard --install-daemon`;
|
||||
- Gateway daemon usage;
|
||||
- agent prompt files `AGENTS.md`, `SOUL.md`, and `TOOLS.md`;
|
||||
- workspace skills at `~/.openclaw/workspace/skills/<skill>/SKILL.md`;
|
||||
- model configuration in OpenClaw config;
|
||||
- security guidance for DM pairing, allowlists, and sandboxing.
|
||||
|
||||
Verify upstream docs before depending on a command in code.
|
||||
|
||||
## Living IP Workspace Shape
|
||||
|
||||
Create or update:
|
||||
|
||||
- `AGENTS.md`: scope, repo boundaries, proof requirements;
|
||||
- `SOUL.md`: Rio or Theseus identity;
|
||||
- `TOOLS.md`: bounded tools only;
|
||||
- `<openclaw-workspace>/skills/decision-engine-refinement/SKILL.md`;
|
||||
- `<openclaw-workspace>/skills/living-ip-kb-interop/SKILL.md`;
|
||||
- `<openclaw-workspace>/skills/teleo-db-operator/SKILL.md` only for read-only
|
||||
local copies unless explicitly authorized.
|
||||
|
||||
## Tool Policy
|
||||
|
||||
Default allow:
|
||||
|
||||
- read files;
|
||||
- run local fixture tests;
|
||||
- write proof artifacts;
|
||||
- inspect git diffs;
|
||||
- query copied SQLite DBs read-only.
|
||||
|
||||
Default deny:
|
||||
|
||||
- production DB writes;
|
||||
- token reads;
|
||||
- Bitwarden vault export;
|
||||
- live GitHub PR comments;
|
||||
- public messaging sends;
|
||||
- broad shell automation against host services.
|
||||
|
||||
## Rio And Theseus
|
||||
|
||||
- Rio OpenClaw package: economic reasoning, contribution incentives, paid-query guardrails, OPSEC.
|
||||
- Theseus OpenClaw package: eval integrity, adversarial prompts, model bakeoffs, self-upgrade review.
|
||||
|
||||
## Proof Contract
|
||||
|
||||
An OpenClaw adapter is useful only if it can run a fixture and produce:
|
||||
|
||||
- prompt files used;
|
||||
- tool allowlist;
|
||||
- model selected;
|
||||
- fixture input;
|
||||
- structured verdict output;
|
||||
- proof that no denied tools were invoked.
|
||||
|
||||
Do not claim OpenClaw production readiness until the package runs in an OpenClaw workspace and writes proof.
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
---
|
||||
name: private-password-storage
|
||||
description: Store or update a private password or credential in the device-local macOS Keychain through a native secure popup, and check presence without retrieval. Use when a user asks to save, store, update, or verify a private credential securely on a Mac.
|
||||
---
|
||||
|
||||
# Private Password Storage
|
||||
|
||||
Use the bundled helper so the credential enters only through a native
|
||||
`NSSecureTextField` and goes directly to Security.framework.
|
||||
|
||||
## Choose The Right Authentication Route
|
||||
|
||||
Prefer the provider's supported credential manager or login flow when one
|
||||
exists. Keychain storage does not authenticate an application and cannot
|
||||
satisfy OAuth, OTP, passkeys, browser sessions, or `gcloud` login by itself.
|
||||
|
||||
Never ask the user to paste a credential into chat.
|
||||
|
||||
## Store Or Update
|
||||
|
||||
Run `--store` only after the user asks to open the secure popup:
|
||||
|
||||
```bash
|
||||
.agents/skills/private-password-storage/scripts/private-password-storage \
|
||||
--service "com.example.application" \
|
||||
--account "operator@example.invalid" \
|
||||
--label "Example application password" \
|
||||
--store
|
||||
```
|
||||
|
||||
The popup accepts normal typing, Cmd+V, and right-click Paste. Empty input
|
||||
stays in the popup and is rejected. A successful write prints only `stored`.
|
||||
The item is stored as `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with
|
||||
synchronization disabled.
|
||||
|
||||
## Check Presence
|
||||
|
||||
```bash
|
||||
.agents/skills/private-password-storage/scripts/private-password-storage \
|
||||
--service "com.example.application" \
|
||||
--account "operator@example.invalid" \
|
||||
--status
|
||||
```
|
||||
|
||||
Status prints only `present` or `absent`. It never returns the credential.
|
||||
|
||||
## Safety Contract
|
||||
|
||||
- Keep the credential out of process arguments, shell variables, files,
|
||||
logs, screenshots, terminal output, clipboard writes, and proof artifacts.
|
||||
- Do not add retrieval, reveal, echo, export, or fingerprint modes.
|
||||
- Do not use AppleScript, `osascript`, System Events, `pbcopy`, or the
|
||||
`security add-generic-password` CLI.
|
||||
- Treat service, account, and label as non-secret metadata; do not print them.
|
||||
- Report only the helper status and whether a native popup was used.
|
||||
|
||||
## Sanitized Canary
|
||||
|
||||
Use only a generated fake value and a service beginning with
|
||||
`dev.codex.private-password-storage.canary.` plus an account beginning with
|
||||
`canary-`. The test-only `--delete-test-item` action is prefix-guarded and
|
||||
deletes only that exact service/account pair. Verify `present`, delete the
|
||||
exact item, then verify `absent`. Do not retain the fake value anywhere.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
interface:
|
||||
display_name: "Private Password Storage"
|
||||
short_description: "Store device-local credentials securely"
|
||||
default_prompt: "Use $private-password-storage to store or update a private credential securely in the macOS Keychain."
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/private_password_storage.py" "$@"
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Launch the native macOS Keychain helper without accepting secret arguments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SafeArgumentParser(argparse.ArgumentParser):
|
||||
def error(self, message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = SafeArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("--service", required=True)
|
||||
parser.add_argument("--account", required=True)
|
||||
parser.add_argument("--label", default="Private credential")
|
||||
actions = parser.add_mutually_exclusive_group(required=True)
|
||||
actions.add_argument("--store", action="store_true")
|
||||
actions.add_argument("--status", action="store_true")
|
||||
actions.add_argument(
|
||||
"--delete-test-item", action="store_true", help=argparse.SUPPRESS
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def validate_metadata(args: argparse.Namespace) -> bool:
|
||||
values = (args.service, args.account, args.label)
|
||||
return all(value and "\x00" not in value for value in values)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def native_command() -> Iterator[tuple[list[str], Path | None] | None]:
|
||||
test_helper = os.environ.get("PRIVATE_PASSWORD_STORAGE_TEST_HELPER")
|
||||
if test_helper:
|
||||
yield ([test_helper], None)
|
||||
return
|
||||
if sys.platform != "darwin":
|
||||
yield None
|
||||
return
|
||||
|
||||
discovery = subprocess.run(
|
||||
["/usr/bin/xcrun", "--find", "swiftc"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if discovery.returncode != 0:
|
||||
yield None
|
||||
return
|
||||
if not discovery.stdout.strip():
|
||||
yield None
|
||||
return
|
||||
|
||||
target = f"{platform.machine()}-apple-macosx12.0"
|
||||
with tempfile.TemporaryDirectory(prefix="private-password-storage-") as temp:
|
||||
app = Path(temp) / "Private Password Storage.app"
|
||||
macos = app / "Contents" / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
executable = macos / "private-password-storage-native"
|
||||
info = {
|
||||
"CFBundleDisplayName": "Private Password Storage",
|
||||
"CFBundleExecutable": executable.name,
|
||||
"CFBundleIdentifier": "dev.codex.private-password-storage",
|
||||
"CFBundleName": "Private Password Storage",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleVersion": "1",
|
||||
"CFBundleShortVersionString": "1.0",
|
||||
"LSMinimumSystemVersion": "12.0",
|
||||
"NSHighResolutionCapable": True,
|
||||
}
|
||||
with (app / "Contents" / "Info.plist").open("wb") as handle:
|
||||
plistlib.dump(info, handle)
|
||||
|
||||
compilation = subprocess.run(
|
||||
[
|
||||
"/usr/bin/xcrun",
|
||||
"swiftc",
|
||||
"-target",
|
||||
target,
|
||||
str(Path(__file__).with_suffix(".swift")),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if compilation.returncode != 0:
|
||||
yield None
|
||||
return
|
||||
yield ([str(executable)], app)
|
||||
|
||||
|
||||
def action_args(args: argparse.Namespace) -> list[str]:
|
||||
if args.store:
|
||||
action = "--store"
|
||||
elif args.status:
|
||||
action = "--status"
|
||||
else:
|
||||
action = "--delete-test-item"
|
||||
return [
|
||||
"--service",
|
||||
args.service,
|
||||
"--account",
|
||||
args.account,
|
||||
"--label",
|
||||
args.label,
|
||||
action,
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
args = parse_args()
|
||||
except (ValueError, SystemExit):
|
||||
sys.stdout.write("error\n")
|
||||
return 64
|
||||
|
||||
if not validate_metadata(args):
|
||||
sys.stdout.write("error\n")
|
||||
return 64
|
||||
|
||||
with native_command() as native:
|
||||
if native is None:
|
||||
sys.stdout.write("error\n")
|
||||
return 69
|
||||
command, app = native
|
||||
try:
|
||||
if args.store and app is not None:
|
||||
result_file = app.parent / "store-status"
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"/usr/bin/open",
|
||||
"-W",
|
||||
"-n",
|
||||
str(app),
|
||||
"--args",
|
||||
*action_args(args),
|
||||
"--result-file",
|
||||
str(result_file),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
output = (
|
||||
result_file.read_text(encoding="utf-8").strip()
|
||||
if result_file.is_file()
|
||||
else ""
|
||||
)
|
||||
else:
|
||||
completed = subprocess.run(
|
||||
[*command, *action_args(args)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
output = completed.stdout.strip()
|
||||
except KeyboardInterrupt:
|
||||
sys.stdout.write("cancelled\n")
|
||||
return 1
|
||||
|
||||
allowed = {
|
||||
"stored": {"stored", "cancelled"},
|
||||
"status": {"present", "absent"},
|
||||
"delete": {"absent"},
|
||||
}
|
||||
expected = "stored" if args.store else "status" if args.status else "delete"
|
||||
if completed.returncode not in (0, 1) or output not in allowed[expected]:
|
||||
sys.stdout.write("error\n")
|
||||
return 1
|
||||
|
||||
sys.stdout.write(f"{output}\n")
|
||||
if expected == "stored" and output != "stored":
|
||||
return 1
|
||||
return completed.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
private enum Action {
|
||||
case store
|
||||
case status
|
||||
case deleteTestItem
|
||||
}
|
||||
|
||||
private struct Options {
|
||||
let service: String
|
||||
let account: String
|
||||
let label: String
|
||||
let action: Action
|
||||
let resultFile: String?
|
||||
}
|
||||
|
||||
private enum StoreResult {
|
||||
case stored
|
||||
case cancelled
|
||||
case failed
|
||||
}
|
||||
|
||||
private enum PresenceResult {
|
||||
case present
|
||||
case absent
|
||||
case failed
|
||||
}
|
||||
|
||||
private func parseOptions() -> Options? {
|
||||
var service: String?
|
||||
var account: String?
|
||||
var label = "Private credential"
|
||||
var action: Action?
|
||||
var resultFile: String?
|
||||
var index = 1
|
||||
let arguments = CommandLine.arguments
|
||||
|
||||
while index < arguments.count {
|
||||
let argument = arguments[index]
|
||||
switch argument {
|
||||
case "--service", "--account", "--label", "--result-file":
|
||||
guard index + 1 < arguments.count else { return nil }
|
||||
let value = arguments[index + 1]
|
||||
guard !value.isEmpty, !value.contains("\0") else { return nil }
|
||||
if argument == "--service" {
|
||||
service = value
|
||||
} else if argument == "--account" {
|
||||
account = value
|
||||
} else if argument == "--label" {
|
||||
label = value
|
||||
} else {
|
||||
resultFile = value
|
||||
}
|
||||
index += 2
|
||||
case "--store", "--status", "--delete-test-item":
|
||||
guard action == nil else { return nil }
|
||||
action = argument == "--store" ? .store
|
||||
: argument == "--status" ? .status : .deleteTestItem
|
||||
index += 1
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
guard let service, let account, let action, !label.isEmpty else { return nil }
|
||||
return Options(
|
||||
service: service,
|
||||
account: account,
|
||||
label: label,
|
||||
action: action,
|
||||
resultFile: resultFile
|
||||
)
|
||||
}
|
||||
|
||||
private func deviceLocalQuery(service: String, account: String) -> [CFString: Any] {
|
||||
[
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrSynchronizable: kCFBooleanFalse as Any,
|
||||
]
|
||||
}
|
||||
|
||||
private func storeInKeychain(
|
||||
service: String,
|
||||
account: String,
|
||||
label: String,
|
||||
secretData: Data
|
||||
) -> Bool {
|
||||
let query = deviceLocalQuery(service: service, account: account)
|
||||
var item = query
|
||||
item[kSecAttrLabel] = label
|
||||
item[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
item[kSecValueData] = secretData
|
||||
|
||||
let addStatus = SecItemAdd(item as CFDictionary, nil)
|
||||
if addStatus == errSecSuccess {
|
||||
return true
|
||||
}
|
||||
guard addStatus == errSecDuplicateItem else {
|
||||
return false
|
||||
}
|
||||
|
||||
let updates: [CFString: Any] = [
|
||||
kSecAttrLabel: label,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
kSecValueData: secretData,
|
||||
]
|
||||
return SecItemUpdate(query as CFDictionary, updates as CFDictionary) == errSecSuccess
|
||||
}
|
||||
|
||||
private func keychainPresence(service: String, account: String) -> PresenceResult {
|
||||
var query = deviceLocalQuery(service: service, account: account)
|
||||
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||
query[kSecReturnData] = kCFBooleanFalse
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
if status == errSecSuccess {
|
||||
return .present
|
||||
}
|
||||
if status == errSecItemNotFound {
|
||||
return .absent
|
||||
}
|
||||
return .failed
|
||||
}
|
||||
|
||||
private func deleteExactCanaryItem(service: String, account: String) -> Bool {
|
||||
guard service.hasPrefix("dev.codex.private-password-storage.canary."),
|
||||
account.hasPrefix("canary-") else {
|
||||
return false
|
||||
}
|
||||
let query = deviceLocalQuery(service: service, account: account)
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SecurePromptController: NSObject, NSWindowDelegate {
|
||||
private let service: String
|
||||
private let account: String
|
||||
private let itemLabel: String
|
||||
private let window: NSWindow
|
||||
private let secureField = NSSecureTextField()
|
||||
private let validationLabel = NSTextField(labelWithString: "")
|
||||
private var result: StoreResult?
|
||||
|
||||
init(service: String, account: String, label: String) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.itemLabel = label
|
||||
self.window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 440, height: 190),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
super.init()
|
||||
configureWindow()
|
||||
}
|
||||
|
||||
private func configureWindow() {
|
||||
window.title = "Store Private Credential"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.center()
|
||||
|
||||
let content = NSView()
|
||||
window.contentView = content
|
||||
|
||||
let title = NSTextField(labelWithString: itemLabel)
|
||||
title.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
title.lineBreakMode = .byTruncatingTail
|
||||
secureField.placeholderString = "Password"
|
||||
secureField.font = NSFont.systemFont(ofSize: 14)
|
||||
|
||||
validationLabel.stringValue = "Password cannot be empty."
|
||||
validationLabel.textColor = .systemRed
|
||||
validationLabel.isHidden = true
|
||||
|
||||
let cancelButton = NSButton(
|
||||
title: "Cancel", target: self, action: #selector(cancelPressed)
|
||||
)
|
||||
cancelButton.keyEquivalent = "\u{1b}"
|
||||
let storeButton = NSButton(
|
||||
title: "Store", target: self, action: #selector(storePressed)
|
||||
)
|
||||
storeButton.keyEquivalent = "\r"
|
||||
|
||||
let pasteMenu = NSMenu()
|
||||
let pasteItem = NSMenuItem(
|
||||
title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: ""
|
||||
)
|
||||
pasteItem.target = nil
|
||||
pasteMenu.addItem(pasteItem)
|
||||
secureField.menu = pasteMenu
|
||||
|
||||
[title, secureField, validationLabel, cancelButton, storeButton].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview($0)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
title.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||
title.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
|
||||
title.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
||||
secureField.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
|
||||
secureField.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
||||
secureField.trailingAnchor.constraint(equalTo: title.trailingAnchor),
|
||||
secureField.heightAnchor.constraint(equalToConstant: 28),
|
||||
validationLabel.topAnchor.constraint(equalTo: secureField.bottomAnchor, constant: 6),
|
||||
validationLabel.leadingAnchor.constraint(equalTo: secureField.leadingAnchor),
|
||||
storeButton.trailingAnchor.constraint(equalTo: secureField.trailingAnchor),
|
||||
storeButton.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20),
|
||||
cancelButton.trailingAnchor.constraint(equalTo: storeButton.leadingAnchor, constant: -10),
|
||||
cancelButton.centerYAnchor.constraint(equalTo: storeButton.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func installPasteMenu() {
|
||||
let mainMenu = NSMenu()
|
||||
let editMenuItem = NSMenuItem()
|
||||
let editMenu = NSMenu(title: "Edit")
|
||||
let pasteItem = NSMenuItem(
|
||||
title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v"
|
||||
)
|
||||
pasteItem.keyEquivalentModifierMask = [.command]
|
||||
pasteItem.target = nil
|
||||
editMenu.addItem(pasteItem)
|
||||
editMenuItem.submenu = editMenu
|
||||
mainMenu.addItem(editMenuItem)
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
|
||||
func run() -> StoreResult {
|
||||
installPasteMenu()
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.makeFirstResponder(secureField)
|
||||
NSApp.run()
|
||||
return result ?? .cancelled
|
||||
}
|
||||
|
||||
@objc private func storePressed() {
|
||||
guard !secureField.stringValue.isEmpty else {
|
||||
validationLabel.stringValue = "Password cannot be empty."
|
||||
validationLabel.isHidden = false
|
||||
NSSound.beep()
|
||||
window.makeFirstResponder(secureField)
|
||||
return
|
||||
}
|
||||
|
||||
validationLabel.isHidden = true
|
||||
var secretData = Data(secureField.stringValue.utf8)
|
||||
let stored = storeInKeychain(
|
||||
service: service,
|
||||
account: account,
|
||||
label: itemLabel,
|
||||
secretData: secretData
|
||||
)
|
||||
secureField.stringValue = ""
|
||||
secretData.resetBytes(in: 0..<secretData.count)
|
||||
guard stored else {
|
||||
validationLabel.stringValue = "The credential could not be stored."
|
||||
validationLabel.isHidden = false
|
||||
return
|
||||
}
|
||||
|
||||
result = .stored
|
||||
window.close()
|
||||
}
|
||||
|
||||
@objc private func cancelPressed() {
|
||||
secureField.stringValue = ""
|
||||
result = .cancelled
|
||||
window.close()
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
secureField.stringValue = ""
|
||||
if result == nil {
|
||||
result = .cancelled
|
||||
}
|
||||
NSApp.stop(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runSecurePrompt(options: Options) -> StoreResult {
|
||||
let application = NSApplication.shared
|
||||
let controller = SecurePromptController(
|
||||
service: options.service,
|
||||
account: options.account,
|
||||
label: options.label
|
||||
)
|
||||
_ = application
|
||||
return controller.run()
|
||||
}
|
||||
|
||||
private func emit(_ value: String, code: Int32, resultFile: String? = nil) -> Never {
|
||||
let data = Data((value + "\n").utf8)
|
||||
if let resultFile {
|
||||
do {
|
||||
try data.write(to: URL(fileURLWithPath: resultFile), options: .atomic)
|
||||
} catch {
|
||||
exit(74)
|
||||
}
|
||||
} else {
|
||||
FileHandle.standardOutput.write(data)
|
||||
}
|
||||
exit(code)
|
||||
}
|
||||
|
||||
guard let options = parseOptions() else {
|
||||
emit("error", code: 64)
|
||||
}
|
||||
|
||||
switch options.action {
|
||||
case .status:
|
||||
switch keychainPresence(service: options.service, account: options.account) {
|
||||
case .present:
|
||||
emit("present", code: 0, resultFile: options.resultFile)
|
||||
case .absent:
|
||||
emit("absent", code: 1, resultFile: options.resultFile)
|
||||
case .failed:
|
||||
emit("error", code: 1, resultFile: options.resultFile)
|
||||
}
|
||||
case .store:
|
||||
let storeResult = MainActor.assumeIsolated {
|
||||
runSecurePrompt(options: options)
|
||||
}
|
||||
switch storeResult {
|
||||
case .stored:
|
||||
emit("stored", code: 0, resultFile: options.resultFile)
|
||||
case .cancelled:
|
||||
emit("cancelled", code: 1, resultFile: options.resultFile)
|
||||
case .failed:
|
||||
emit("error", code: 1, resultFile: options.resultFile)
|
||||
}
|
||||
case .deleteTestItem:
|
||||
guard deleteExactCanaryItem(service: options.service, account: options.account) else {
|
||||
emit("error", code: 1, resultFile: options.resultFile)
|
||||
}
|
||||
emit("absent", code: 0, resultFile: options.resultFile)
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
---
|
||||
name: teleo-corpus-knowledge-rebuild
|
||||
description: Use when locally snapshotting an extracted UTF-8 Forgejo or text corpus into a deterministic Teleo source library, or sharding its verified extraction-work index for parallel processing before any claim generation, review, admission, or database write.
|
||||
---
|
||||
|
||||
# Teleo Corpus Knowledge Rebuild
|
||||
|
||||
## Job
|
||||
|
||||
Turn one local UTF-8 source directory into a deterministic, self-contained
|
||||
source snapshot bundle. This is the source-library phase only: it preserves
|
||||
bytes, paths, hashes, and syntactic section locators without interpreting any
|
||||
section as a claim. It does not perform semantic claim extraction.
|
||||
|
||||
After that inventory verifies, optionally divide its exact work rows into
|
||||
deterministic local shards so independent extractors can process a large corpus
|
||||
in parallel. Sharding schedules work; it does not interpret source content.
|
||||
|
||||
## Operator Path
|
||||
|
||||
From the repository root, build into a path that does not exist:
|
||||
|
||||
```bash
|
||||
python3 ops/run_local_corpus_knowledge_rebuild.py \
|
||||
--source-root tests/fixtures/teleo_corpus_rebuild \
|
||||
--output /private/tmp/teleo-corpus-rebuild-bundle \
|
||||
--mode source-inventory-only
|
||||
```
|
||||
|
||||
Then run the read-only verifier:
|
||||
|
||||
```bash
|
||||
python3 ops/run_local_corpus_knowledge_rebuild.py \
|
||||
--verify-bundle /private/tmp/teleo-corpus-rebuild-bundle
|
||||
```
|
||||
|
||||
For parallel processing, choose a shard count from 1 through 1024 that does not
|
||||
exceed the verified work-item count. Build into another path that does not
|
||||
exist:
|
||||
|
||||
```bash
|
||||
python3 ops/shard_corpus_extraction_work.py \
|
||||
--inventory-bundle /private/tmp/teleo-corpus-rebuild-bundle \
|
||||
--output-dir /private/tmp/teleo-corpus-extraction-shards \
|
||||
--shard-count 64
|
||||
```
|
||||
|
||||
Then verify the shards against the same inventory:
|
||||
|
||||
```bash
|
||||
python3 ops/shard_corpus_extraction_work.py \
|
||||
--inventory-bundle /private/tmp/teleo-corpus-rebuild-bundle \
|
||||
--verify-bundle /private/tmp/teleo-corpus-extraction-shards
|
||||
```
|
||||
|
||||
Inspect these stable entry points:
|
||||
|
||||
```text
|
||||
source-manifest.json
|
||||
extraction-work-index.json
|
||||
receipt.json
|
||||
snapshots/sha256/<prefix>/<sha256>.utf8
|
||||
shard-manifest.json
|
||||
shards/shard-<index>-of-<count>.json
|
||||
```
|
||||
|
||||
## Required Readback
|
||||
|
||||
Require all of these:
|
||||
|
||||
- every source row binds a normalized source-root-relative path to one exact,
|
||||
content-addressed byte snapshot;
|
||||
- every section has exact one-based line and UTF-8 byte ranges plus a quote
|
||||
hash;
|
||||
- every work row is `source_section_for_semantic_extraction`;
|
||||
- `semantic_claim_extraction_performed=false`,
|
||||
`database_write_performed=false`, and `admission_performed=false` throughout;
|
||||
- the receipt says the directory snapshot was not atomic and only guarantees
|
||||
the exact files copied from stable descriptors;
|
||||
- the verifier recomputes snapshots, locators, cardinality, artifact hashes,
|
||||
bundle-tree hash, and receipt hash;
|
||||
- a second run over unchanged inputs produces byte-identical output.
|
||||
- every inventory work item appears in exactly one shard selected by
|
||||
`sha256(utf8(work_item_id)) mod shard_count`;
|
||||
- each shard binds the exact inventory tree, source-manifest, work-index, and
|
||||
inventory-receipt hashes without embedding source-body text or absolute paths;
|
||||
- the shard verifier rejects wrong inventories, missing, duplicate, unknown, or
|
||||
misplaced work IDs, symlinks, extras, incomplete output, and rehashed
|
||||
side-effect overclaims;
|
||||
- shard directories are `0700`, files are `0600`, and repeated runs with the
|
||||
same inventory and shard count are byte-identical.
|
||||
|
||||
Run focused checks:
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q tests/test_run_local_corpus_knowledge_rebuild.py
|
||||
python3 -m pytest -q tests/test_shard_corpus_extraction_work.py
|
||||
python3 -m ruff check \
|
||||
ops/run_local_corpus_knowledge_rebuild.py \
|
||||
ops/shard_corpus_extraction_work.py \
|
||||
tests/test_run_local_corpus_knowledge_rebuild.py \
|
||||
tests/test_shard_corpus_extraction_work.py
|
||||
```
|
||||
|
||||
## Safety Boundary
|
||||
|
||||
The build reserves a new output directory atomically and never replaces an
|
||||
existing destination. It publishes `receipt.json` while the incomplete marker
|
||||
is still present, then removes that marker as the final completion step. It
|
||||
opens source files descriptor-first without following
|
||||
symlinks, streams exact bytes into private content-addressed snapshots, rejects
|
||||
invalid UTF-8, binary controls, lone CR newlines, unsupported entries, path
|
||||
collisions, and configured resource-limit overruns, and deduplicates identical
|
||||
content.
|
||||
|
||||
The defaults are bounded. Raise them only with the explicit `--max-*` options;
|
||||
the command enforces hard upper bounds on every override.
|
||||
|
||||
The sharder independently verifies the inventory before reading work metadata,
|
||||
reserves a new output directory without replacement, writes through held
|
||||
directory descriptors, keeps an incomplete marker through receipt publication,
|
||||
and revalidates the inventory before publishing. It reads no snapshot bodies.
|
||||
Hash shards can be empty; membership is stable only for the exact inventory and
|
||||
the same shard count.
|
||||
|
||||
## Semantic Handoff
|
||||
|
||||
The inventory plus one verified shard is input to a separate semantic
|
||||
extractor, not to a database writer. The extractor must verify both bundles,
|
||||
then resolve each relative snapshot path and exact byte range from the supplied
|
||||
inventory. Only after that extractor has produced explicit, source-backed V3
|
||||
manifests may the operator run:
|
||||
|
||||
```text
|
||||
scripts/compile_kb_source_packet.py
|
||||
```
|
||||
|
||||
That compiler's output still requires separate review and guarded admission.
|
||||
Do not fabricate V3 manifests from raw sections in this inventory helper.
|
||||
|
||||
Use `teleo-reconstruction-recovery` for restore/replay selection and
|
||||
`teleo-kb-db-change-workflow` only for an explicitly authorized later review or
|
||||
apply task.
|
||||
|
||||
## Claim Ceiling
|
||||
|
||||
The output proves deterministic local inventory of the exact copied UTF-8
|
||||
files, bounded syntactic sectioning, bundle integrity, and an exact once-only
|
||||
hash partition of extraction work for a fixed shard count. It does not prove an
|
||||
atomic snapshot of concurrent directory changes, non-empty shards,
|
||||
format-specific syntax, semantic extraction or extractor quality, claim
|
||||
atomicity or correctness, database deduplication, review, admission,
|
||||
application, binary-document extraction, or any live environment.
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
name: teleo-db-operator
|
||||
description: Use when reading, auditing, backing up, querying, or safely writing the Teleo pipeline SQLite database, including review_records, audit_log, costs, prs, sources, and contributor feedback loops.
|
||||
---
|
||||
|
||||
# Teleo DB Operator
|
||||
|
||||
Default to read-only. The database is evidence for decision-engine refinement, not a scratchpad.
|
||||
|
||||
## Discover
|
||||
|
||||
1. Read `lib/config.py` for `DB_PATH` and related paths.
|
||||
2. Prefer local or copied DBs over production DBs.
|
||||
3. If using production, record whether access is read-only or write-authorized.
|
||||
4. Never print secret values found near DB paths or shell history.
|
||||
|
||||
## Read Path
|
||||
|
||||
Use `sqlite3` or Python `sqlite3`.
|
||||
|
||||
Recommended read targets:
|
||||
|
||||
- `review_records`: evaluator, model, outcome, rejection reason.
|
||||
- `audit_log`: route decisions, approve/reject events, failure details.
|
||||
- `costs`: model cost by date/stage.
|
||||
- `prs`: status, tier, route compatibility fields, verdicts.
|
||||
- `sources`: priority, feedback, extraction model.
|
||||
|
||||
For refinement work, export aggregated JSON or CSV into `.crabbox-results/` or `proof/`, not raw private DB snapshots.
|
||||
|
||||
## Write Path
|
||||
|
||||
Writes require explicit authorization and a backup.
|
||||
|
||||
Required sequence:
|
||||
|
||||
1. Create a backup or operate on a copy.
|
||||
2. Write the exact SQL in a retained artifact.
|
||||
3. Use `BEGIN IMMEDIATE;`.
|
||||
4. Apply the minimal mutation.
|
||||
5. Read back the changed rows.
|
||||
6. Commit the transaction only after readback is correct.
|
||||
7. Write a blocker artifact instead of guessing if any precondition is missing.
|
||||
|
||||
Never write production prompt/model state as part of an experiment. Experiments should replay fixtures and produce proof first.
|
||||
|
||||
## Safety Boundaries
|
||||
|
||||
- Do not attach, copy, or commit `pipeline.db`.
|
||||
- Do not run broad `UPDATE` or `DELETE` without a `WHERE` clause and a prior row count.
|
||||
- Do not mutate `prs`, `sources`, or contributor state from a model response alone.
|
||||
- Do not treat local copied DB proof as production proof.
|
||||
|
||||
## Useful Queries
|
||||
|
||||
```sql
|
||||
SELECT reviewer, reviewer_model, outcome, rejection_reason, count(*) AS n
|
||||
FROM review_records
|
||||
GROUP BY reviewer, reviewer_model, outcome, rejection_reason
|
||||
ORDER BY n DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT event, count(*) AS n
|
||||
FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
GROUP BY event
|
||||
ORDER BY n DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT model, stage, calls, input_tokens, output_tokens, cost_usd
|
||||
FROM costs
|
||||
ORDER BY date DESC, cost_usd DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
---
|
||||
name: teleo-gcp-parity-ops
|
||||
description: Use for passwordless Teleo GCP VM access, private Cloud SQL canonical parity, GCP Leo runtime readback, m3taversal no-send replay, rollback, and cleanup without collapsing VPS proof into GCP proof.
|
||||
---
|
||||
|
||||
# Teleo GCP Parity Ops
|
||||
|
||||
## Working Target
|
||||
|
||||
Restore a copy of the VPS canonical Leo database to GCP, prove exact schema,
|
||||
row, role membership, ownership, ACL, extension, performance, and
|
||||
private-connectivity parity, then run the
|
||||
real GCP Leo read/reasoning path without Telegram sends or DB mutation.
|
||||
|
||||
The required tier is T3: a live private GCP staging restore, readback,
|
||||
reasoning, compute attestation, and cleanup receipt. Local restore proof or an
|
||||
access-gate report does not satisfy this tier.
|
||||
|
||||
## Operator Paths
|
||||
|
||||
The direct alias was passwordless and live-verified on 2026-07-14:
|
||||
|
||||
```bash
|
||||
ssh teleo-gcp-staging
|
||||
```
|
||||
|
||||
It uses `~/.ssh/google_compute_engine` for the configured operator,
|
||||
disables password and keyboard-interactive authentication, and does not store a
|
||||
Google password. That is historical proof, not current reachability. The route
|
||||
remains firewall-source-dependent. On
|
||||
2026-07-15, both direct SSH and the private TCP route timed out; verify
|
||||
`ssh -o BatchMode=yes teleo-gcp-staging true` before a long run and fail closed
|
||||
if it does not return successfully.
|
||||
|
||||
The intended secondary path is `.github/workflows/gcp-iap-operator.yml`, using
|
||||
short-lived GitHub OIDC, IAP, OS Login, and fixed reviewed operations. It is
|
||||
merged but not bootstrapped: the current 2026-07-15 check still lacks the
|
||||
reviewed operator WIF/IAP authority. Provider `teleo-iap-operator` is absent,
|
||||
disabled, or deleted (`invalid_target`), and the available artifact identity is intentionally not
|
||||
a Cloud SQL/Compute operator. Do not call this path working until a live `status` run passes
|
||||
with the intended operator identity.
|
||||
|
||||
Historically verified and intended target; reverify before any new lifecycle:
|
||||
|
||||
- project `teleo-501523`;
|
||||
- VM `teleo-prod-1` in `europe-west6-a`;
|
||||
- Cloud SQL `teleo-pgvector-standby`, PostgreSQL 16.14;
|
||||
- private endpoint `10.61.0.3:5432`, public IP disabled, TLS required;
|
||||
- restore identity `sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com`;
|
||||
- service `leoclean-gcp-prod-parallel.service`.
|
||||
|
||||
Direct libpq connections must use `sslmode=verify-full`, a reviewed DNS name
|
||||
covered by the server certificate, and a private regular CA file. The private
|
||||
IP is `hostaddr`, not the certificate identity. Never downgrade to
|
||||
`sslmode=require` or accept an arbitrary `*.iam.gserviceaccount.com` identity.
|
||||
|
||||
Never print the Cloud SQL or Google password. On the VM, use the attached service account
|
||||
and Secret Manager through reviewed wrappers or a short-lived environment
|
||||
variable, then unset it.
|
||||
|
||||
## Two Different Databases
|
||||
|
||||
- Canonical collective knowledge remains VPS PostgreSQL database `teleo`, with
|
||||
`public.*` and `kb_stage.*` tables. Persistent Cloud SQL database
|
||||
`teleo_canonical` is a staging copy and has not been promoted.
|
||||
- Hermes conversation continuity is `state.db` plus session JSONL files. The
|
||||
`leoclean-cloudsql-memory-sync.service` snapshot path copies this runtime
|
||||
memory; it does not populate or prove canonical claims, sources, evidence,
|
||||
edges, or proposals.
|
||||
|
||||
Do not describe a passing Hermes memory sync as canonical KB parity.
|
||||
|
||||
## Retained Historical Proof - 2026-07-14
|
||||
|
||||
- The newest captured VPS database has `39` tables and `52,167` rows, including
|
||||
claims `1837`, sources `4145`, claim evidence `4670`, claim edges `4916`, and
|
||||
proposals `29`.
|
||||
- A disposable private-TLS GCP clone restored that snapshot with exact
|
||||
`39/39`-table and `52,167/52,167`-row parity. Rowsets, schema objects, roles,
|
||||
extensions, constraints, and performance checks had zero mismatches.
|
||||
- A real no-send GCP Hermes turn received an ID-free claim challenge, performed
|
||||
`search`, `show`, `evidence`, and `edges`, retrieved the expected claim and
|
||||
both source rows, and passed `18/18` runtime checks plus `6/6` reasoning
|
||||
outcomes. It did not send Telegram or write the DB.
|
||||
- `status` and zero-hit search work on a canonical-only clone without the
|
||||
optional `teleo_restore` audit schema. All read commands emit deterministic
|
||||
retrieval receipts.
|
||||
- The generated clone was deleted. The retained rollback database remains
|
||||
connection-disabled with zero sessions. The GCP gateway remained PID
|
||||
`148735`, `NRestarts=0`, active/running throughout that bounded experiment.
|
||||
- PR `#144` merged the reviewed helper/skill. After deployment, the service
|
||||
survived a controlled restart and is active/running at PID `304036`, start
|
||||
time `2026-07-14 10:40:17 UTC`. Live post-restart `status` and `search`
|
||||
returned Cloud SQL retrieval receipts with unchanged canonical counts.
|
||||
- A live regression showed the old wrapper could time out its stronger status
|
||||
probe and fall through to a different local tool. Supported GCP KB commands
|
||||
now route directly to Cloud SQL and fail closed on errors; two behavioral
|
||||
tests enforce that invariant.
|
||||
- Persistent GCP `teleo_canonical` remains the older staging copy measured at
|
||||
`52,164` rows and `26` proposals. It has not been promoted or cut over.
|
||||
|
||||
That historical clone predates the expanded ownership/ACL and independently
|
||||
authenticated receipt contract. It remains useful historical row/schema proof,
|
||||
but it is not current T3 proof under this skill.
|
||||
|
||||
## Open Least-Privilege Candidates
|
||||
|
||||
PR #148, `Scope GCP Leo runtime to least-privilege Cloud SQL access`, is open as
|
||||
of the 2026-07-15 skill-pack reconciliation. Draft PR #162, `Harden GCP Leo
|
||||
runtime least-privilege proof`, is its current hardening successor. They propose
|
||||
scoped runtime roles, secret access, fail-closed Cloud SQL behavior, deployment
|
||||
rollback, and positive plus negative permission checks. Those branch files and
|
||||
proposed live outcomes are candidate evidence only. Before using them, run:
|
||||
|
||||
```bash
|
||||
gh pr view 148 --json state,mergedAt,mergeCommit,headRefName,url
|
||||
gh pr view 162 --json state,mergedAt,mergeCommit,headRefName,url
|
||||
```
|
||||
|
||||
Until a successor is merged and its runtime receipt passes, use only paths present on
|
||||
canonical `main`, keep persistent GCP classified as staging, do not promote it,
|
||||
and do not infer least-privilege cutover from a branch or dry run.
|
||||
|
||||
Primary retained proof:
|
||||
|
||||
- `docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md`
|
||||
- `docs/reports/leo-working-state-20260709/gcp-db-first-restore-current.json`
|
||||
- `docs/reports/leo-working-state-20260709/gcp-db-first-parity-current.json`
|
||||
- `docs/reports/leo-working-state-20260709/gcp-db-first-blind-claim-current.json`
|
||||
- `docs/reports/leo-working-state-20260709/gcp-db-first-cleanup-current.json`
|
||||
- `docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json`
|
||||
|
||||
## Required Parity Rows
|
||||
|
||||
Track these independently:
|
||||
|
||||
1. control-plane project, VM, Cloud SQL, private-IP, and TLS identity;
|
||||
2. canonical database schema, counts, row hashes, constraints, indexes,
|
||||
functions, extensions, exact role attributes, connection limits,
|
||||
`default_transaction_read_only` settings and memberships, owners,
|
||||
normalized schema/relation/column/function/type ACLs, and performance;
|
||||
3. GCP service PID/restarts plus live profile and tool hashes;
|
||||
4. `DC-01` through `DC-06` DB-read readiness;
|
||||
5. real no-send model replies, strict score, and exact count consistency;
|
||||
6. no-mutation fingerprints, child/profile cleanup, clone deletion, and rollback
|
||||
disposition.
|
||||
|
||||
## Canonical Restore And Replay
|
||||
|
||||
Use:
|
||||
|
||||
- `ops/capture_vps_canonical_postgres_snapshot.py` for a single source dump and
|
||||
manifest, with a required retained `--authorization-ref` and v2 provenance
|
||||
receipt binding the exported snapshot identity plus dump/manifest hashes; it
|
||||
requires identical healthy structured source-service states before/after
|
||||
(`active/running`, positive PID, nonnegative restart count);
|
||||
- `ops/restore_gcp_generated_postgres_snapshot.py restore --execute` for a
|
||||
receipt-bound private-TLS restore into a bounded `teleo_clone_*` database;
|
||||
pass the exact capture `--source-receipt`, and require its live Cloud SQL
|
||||
control-plane check to prove public IP disabled before clone creation; it
|
||||
requires the exact restore service account plus `--ssl-server-name` and
|
||||
`--ssl-root-cert` for certificate-authenticated `verify-full` TLS; it
|
||||
writes `target-manifest.jsonl`, `restore-receipt.json`, and the generated
|
||||
`gcp-private-connectivity.json` under
|
||||
`/var/lib/teleo-gcp-restore-runs/<restore-run-id>/`;
|
||||
- `ops/restore_gcp_generated_postgres_snapshot.py cleanup --execute` for exact
|
||||
clone cleanup with live-service and rollback readback;
|
||||
- `ops/postgres_parity_manifest.sql` for row/catalog/role/performance readback;
|
||||
- `ops/verify_postgres_parity_manifest.py --scope gcp_staging` for exact parity,
|
||||
always retaining the canonical output name `gcp-parity.json`;
|
||||
- `scripts/run_gcp_generated_db_direct_claim_suite.py` for the adapter-free,
|
||||
read-only, no-send six-response replay against a generated `teleo_clone_*`.
|
||||
- `scripts/run_gcp_generated_db_blind_claim_canary.py` for the bounded ID-free
|
||||
claim challenge, source receipt, reasoning, no-write, and cleanup proof.
|
||||
- `ops/attest_gcp_reasoning_compute.py` on the replay VM after blind reasoning
|
||||
and before cleanup, binding the reasoning receipt SHA-256 to live GCE metadata
|
||||
in `reasoning-compute-attestation.json`;
|
||||
- a second authorized source capture after cleanup, followed by
|
||||
`ops/verify_vps_canonical_snapshot_delta.py`, to retain the explicit
|
||||
post-snapshot VPS delta in `source-delta-receipt.json`;
|
||||
- `ops/verify_gcp_canonical_lifecycle.py` after cleanup to bind the exact source,
|
||||
current source, source delta, restore, `gcp_staging` parity, ID-free reasoning,
|
||||
reasoning-compute attestation, and cleanup receipts into one fail-closed
|
||||
preparation verdict. Caller-writable JSON can never emit T3 from this API;
|
||||
even an internally valid bundle returns
|
||||
`prepared_external_attestation_required`, `current_tier=T2`, and
|
||||
`accepted_by_this_api=false`. Pass `--max-postflight-age-seconds 900` and
|
||||
`--max-lifecycle-age-seconds 3600`; the latter bounds both cleanup-to-verdict
|
||||
age and restore-to-postflight span.
|
||||
|
||||
With `TARGET_DB`, `RESTORE_RUN_ID`, and `PRIVATE_RUN_DIR` set as shown in the
|
||||
runbook, run the replay-host attestation after the blind receipt passes and
|
||||
before the receipt-bound cleanup:
|
||||
|
||||
```bash
|
||||
sudo python3 ops/attest_gcp_reasoning_compute.py \
|
||||
--reasoning-receipt "${PRIVATE_RUN_DIR}/blind-reasoning-receipt.json" \
|
||||
--restore-run-id "$RESTORE_RUN_ID" \
|
||||
--target-db "$TARGET_DB" \
|
||||
--project teleo-501523 \
|
||||
--expected-compute-instance teleo-prod-1 \
|
||||
--expected-compute-zone europe-west6-a \
|
||||
--expected-private-network projects/teleo-501523/global/networks/teleo-staging-net \
|
||||
--expected-restore-service-account sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com \
|
||||
--output "${PRIVATE_RUN_DIR}/reasoning-compute-attestation.json"
|
||||
```
|
||||
|
||||
The replay must use the Hermes virtualenv, not system Python:
|
||||
|
||||
```bash
|
||||
/home/teleo/.hermes/hermes-agent/venv/bin/python \
|
||||
scripts/run_gcp_generated_db_direct_claim_suite.py ...
|
||||
```
|
||||
|
||||
The lifecycle is not complete until the generated clone, temporary profile,
|
||||
children, transient upload/bundle directories, and any temporary client are
|
||||
absent. Preserve the private receipt run directory and every useful lifecycle
|
||||
input, supporting receipt, and verifier output; do not treat that evidence as
|
||||
transient cleanup. The canonical restore streams through `pg_restore` and
|
||||
creates no GCS import object.
|
||||
|
||||
The source snapshot retains ACL commands while omitting owner commands. The
|
||||
networkless local preflight can therefore replay captured ACLs after creating
|
||||
only the exact allowlisted application roles, plus the bounded database-level
|
||||
grants that `pg_restore` does not apply to an already-created database. The
|
||||
shared-Cloud-SQL restore still uses `--no-owner` and `--no-privileges`, so GCP
|
||||
parity must fail until a separately reviewed Cloud-SQL-compatible authorization
|
||||
replay restores the captured owner/grant semantics. Do not bypass this by
|
||||
dropping manifest rows or by running the Docker/superuser role bootstrap
|
||||
blindly on shared Cloud SQL.
|
||||
|
||||
Static inspection or a dry run is insufficient for these helpers: verify a new
|
||||
capture against the live VPS and run the focused tests. A current T3 claim also
|
||||
requires a separate independently authenticated provider/platform receipt that
|
||||
the bundle author cannot mint.
|
||||
|
||||
## Safety And Claim Ceiling
|
||||
|
||||
- Do not send Telegram messages from the GCP parity lane.
|
||||
- Do not apply, approve, or stage KB changes during read/reasoning replay.
|
||||
- Do not restart the live GCP gateway for tool-file synchronization unless a
|
||||
separate restart window is explicitly requested.
|
||||
- Do not call control-plane inventory, memory sync, route readiness, or a
|
||||
nominal scorer pass full m3taversal parity.
|
||||
|
||||
The strongest claim emitted by the repository lifecycle verifier is T2
|
||||
structural consistency. Exact DB parity plus real no-send model replies with
|
||||
truthful counts and cleanup still requires independent T3 attestation. Neither proves Telegram
|
||||
delivery, GCP canonical mutation, ongoing replication, or production cutover.
|
||||
|
||||
## Current Access And Next Action
|
||||
|
||||
As of 2026-07-15, direct SSH and private TCP timed out, local `gcloud` for
|
||||
`billy@livingip.xyz` requires reauthentication, and the intended operator
|
||||
WIF/IAP authority is unavailable. The artifact-builder WIF identity must not be
|
||||
expanded or mistaken for database-operator access. Current retained T2/source
|
||||
and local restore proof does not satisfy the required T3 lifecycle.
|
||||
|
||||
The next action after an authorized operator route is restored is: newest
|
||||
authorized VPS capture -> disposable private `teleo_clone_*` restore ->
|
||||
generated private-connectivity receipt -> `gcp-parity.json` -> no-send
|
||||
m3taversal blind reasoning -> live GCE reasoning attestation -> receipt-bound
|
||||
clone cleanup -> postflight VPS capture/delta -> eight-receipt lifecycle verdict
|
||||
within the 900/3600-second freshness bounds. Do not promote
|
||||
`teleo_canonical`, expose Cloud SQL publicly, send Telegram, or mutate canonical
|
||||
knowledge.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
---
|
||||
name: teleo-infra-provenance
|
||||
description: Use when tracing Teleo repo/deploy/runtime provenance, preventing path confusion between GitHub, local checkouts, VPS mirrors, live service cwd, and generated report artifacts.
|
||||
---
|
||||
|
||||
# Teleo Infrastructure Provenance
|
||||
|
||||
## Job
|
||||
|
||||
Prevent incorrect assumptions about where Leo/Teleo code, runtime state, DB state, and evidence live.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "where is Leo running from"
|
||||
- "VPS provenance"
|
||||
- "teleo-infrastructure repo"
|
||||
- "deploy source"
|
||||
- "why /opt/teleo-eval"
|
||||
- "what is canonical"
|
||||
|
||||
## Surfaces To Separate
|
||||
|
||||
- GitHub/canonical repo truth.
|
||||
- Local Codex workspace checkout.
|
||||
- VPS live deploy/source mirror.
|
||||
- Hermes/leoclean runtime profile.
|
||||
- Postgres canonical DB.
|
||||
- `kb_stage` proposal ledger.
|
||||
- Caller-specific external output directories, which are retained evidence but
|
||||
are not repository routes unless copied into the repo evidence pack.
|
||||
- Synced report artifacts under the VPS profile report directory.
|
||||
|
||||
## Current Known Path Map
|
||||
|
||||
- Canonical code/deployment repository: GitHub `living-ip/teleo-infrastructure`.
|
||||
- Repo-local evidence pack: `docs/reports/leo-working-state-20260709/`
|
||||
- VPS deploy/source area: `/opt/teleo-eval/workspaces/deploy-infra`
|
||||
- VPS deploy stamp: `/opt/teleo-eval/.last-deploy-sha`
|
||||
- Auto-sync: `teleo-auto-deploy.timer` checks `main` every two minutes.
|
||||
- VPS profile reports: `/home/teleo/.hermes/profiles/leoclean/kb_stage/reports/`
|
||||
- Live service: `leoclean-gateway.service`
|
||||
|
||||
## Required Readbacks
|
||||
|
||||
Before claiming provenance:
|
||||
|
||||
1. `git -C <path> status --short --branch`
|
||||
2. `git -C <path> rev-parse HEAD`
|
||||
3. `/opt/teleo-eval/.last-deploy-sha`
|
||||
4. latest `teleo-auto-deploy.service` journal decision
|
||||
5. service PID, start timestamp, restart count, `WorkingDirectory`, and `User`
|
||||
6. commit delta and file checksums for the runtime paths being compared
|
||||
7. latest retained report artifact timestamp
|
||||
|
||||
## Common Failure
|
||||
|
||||
Do not say the repo/deploy split is the direct cause of a Leo outage unless
|
||||
evidence ties it to the active failure. The split can be a
|
||||
reproducibility/parity risk without being the immediate runtime cause. A commit
|
||||
present in the deploy checkout or stamp does not prove a gateway restart,
|
||||
runtime-profile change, permission migration, worker enablement, or DB apply.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- current repo HEADs,
|
||||
- dirty state,
|
||||
- live service cwd/user,
|
||||
- which path owns code changes,
|
||||
- which path owns runtime reports,
|
||||
- exact stale/provenance risk if any.
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
---
|
||||
name: teleo-kb-db-change-workflow
|
||||
description: Use for Teleo document-to-claim composition, proposal normalization, separate review/apply authority, isolated apply canaries, row-level proof, rollback, and production-apply gating.
|
||||
---
|
||||
|
||||
# Teleo KB DB Change Workflow
|
||||
|
||||
## Working Target
|
||||
|
||||
Turn a source document, post, or operator correction into source-bound evidence,
|
||||
atomic claims, graph links, and a reviewable proposal; then move an approved
|
||||
change into exact canonical rows only through a guarded apply transaction.
|
||||
|
||||
Composition and application are separate capabilities. A synthetic apply test
|
||||
does not prove source ingestion, and a staged source extraction does not prove
|
||||
canonical application.
|
||||
|
||||
## State Model
|
||||
|
||||
- `pending_review`: staged, not approved, canonical rows unchanged.
|
||||
- `approved`: reviewed intent exists, but `applied_at` may still be null.
|
||||
- `applied`: guarded transaction and canonical postflight completed.
|
||||
- `packet_ready_not_executed`: rehearsed artifacts exist; production is
|
||||
unchanged.
|
||||
|
||||
Never infer `applied` from chat text, a packet, a clone, or `status=approved`.
|
||||
|
||||
## Composition Contract
|
||||
|
||||
A useful composed change must retain:
|
||||
|
||||
1. immutable source locator, captured bytes or excerpt, and SHA-256 binding;
|
||||
2. atomic claim body plus type, confidence, tags, and provenance metadata;
|
||||
3. exact evidence excerpt linked to both claim and source;
|
||||
4. graph edges, conflict/update/supersession candidates, and uncertainty;
|
||||
5. duplicate and existing-row search before staging;
|
||||
6. a proposal payload that accounts for every intended canonical row.
|
||||
|
||||
Do not flatten strategy, governance, concept maps, identity, and reasoning tools
|
||||
into generic claim rows merely because the input document discusses them.
|
||||
|
||||
## V3 Source-To-Rebuild Route
|
||||
|
||||
For new reconstruction work, use this order:
|
||||
|
||||
1. Run `ops/run_local_corpus_knowledge_rebuild.py` to retain exact UTF-8
|
||||
snapshots and section work items. Its output contains no semantic claims.
|
||||
2. For a large corpus, run `ops/shard_corpus_extraction_work.py` to create
|
||||
deterministic exact-once work partitions. Sharding schedules extraction; it
|
||||
does not read source bodies or produce claims.
|
||||
3. Have a separately pinned extractor produce an explicit
|
||||
`livingip.kbSourceExtractionManifest.v3`. Every evidence quote must bind to
|
||||
exact UTF-8 byte offsets in the retained extraction.
|
||||
4. Run `scripts/compile_kb_source_packet.py` with the original artifact, exact
|
||||
extracted text, manifest, and extractor specification. The result is
|
||||
build-only and remains `pending_review`.
|
||||
5. On a disposable PostgreSQL clone, stage with
|
||||
`scripts/stage_normalized_proposal.py --compiled-v3-bundle ...`, then render
|
||||
the exact rows with `scripts/kb_proposal_review_packet.py`.
|
||||
6. Record a real review separately with `scripts/approve_proposal.py` and exact
|
||||
`--reviewed-by m3taversal`. Never use test-entered review metadata as
|
||||
evidence that `m3taversal` actually reviewed a production packet.
|
||||
7. Apply only the exact UUID with `scripts/apply_worker.py`, `--enable`,
|
||||
`--contract-version 3`, and `--proposal-id ...`; refuse rather than draining
|
||||
another approved proposal from the queue.
|
||||
8. Export the applied transition with
|
||||
`scripts/export_kb_transition_replay_material.py`, assemble ordered V3
|
||||
materials with `ops/build_v3_genesis_ledger_bundle.py`, and independently
|
||||
replay with `ops/run_local_genesis_ledger_rebuild.py`.
|
||||
|
||||
This repository does not yet provide a general PDF/office extractor or an
|
||||
automatic corpus-wide semantic extractor. For binary originals, retain their
|
||||
exact bytes and pin the separately produced UTF-8 extraction plus extractor
|
||||
specification before step 3.
|
||||
|
||||
Run the deterministic source-to-proposal canary:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/run_leo_local_ingestion_proposal_canary.py \
|
||||
--fixture fixtures/working-leo/document-ingestion-v1.json \
|
||||
--output /tmp/leo-local-ingestion-proposal-canary-current.json
|
||||
```
|
||||
|
||||
Run the complete disposable V3 source-to-clean-rebuild lifecycle:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest -q tests/test_v3_source_rebuild_lifecycle_postgres.py
|
||||
```
|
||||
|
||||
This lifecycle uses a synthetic pinned extractor manifest and synthetic
|
||||
operator-supplied review metadata. It proves the local machinery and exact
|
||||
replay boundary, not semantic extraction quality, real human approval, or a
|
||||
live database mutation.
|
||||
|
||||
Require the source, claim, evidence, and proposal UUIDs to link exactly; claim
|
||||
and evidence text must be exact substrings of the source; status must remain
|
||||
`pending_review`; the container must use network `none` and leave no volume or
|
||||
container behind.
|
||||
|
||||
## Canonical Apply Contract
|
||||
|
||||
The strict `approve_claim` v2 contract remains supported for its retained
|
||||
proofs. V2/V3 payloads may create the supported canonical rows for their exact
|
||||
contract version; V3 additionally carries explicit provenance, access,
|
||||
assessment, and admission fields.
|
||||
|
||||
The shared row families include:
|
||||
|
||||
- `public.claims`
|
||||
- `public.sources`
|
||||
- `public.claim_evidence`
|
||||
- `public.claim_edges`
|
||||
- `public.reasoning_tools`
|
||||
|
||||
The lifecycle is split across:
|
||||
|
||||
- `scripts/compile_kb_source_packet.py`: hash-bound source plus V3 semantic
|
||||
manifest to a deterministic build-only bundle;
|
||||
- `scripts/stage_normalized_proposal.py`: revalidate and stage an exact bundle
|
||||
only on the named disposable database;
|
||||
- `scripts/kb_proposal_review_packet.py`: read-only review of exact proposed
|
||||
canonical rows;
|
||||
- `scripts/kb_proposal_normalize.py`: fail-closed rich intent to strict payload;
|
||||
- `scripts/approve_proposal.py`: `kb_review` approves the exact type/payload;
|
||||
- `scripts/apply_proposal.py`: operator-only `kb_apply` writes and verifies;
|
||||
- `scripts/apply_worker.py`: exact-ID, one-at-a-time V2/V3 worker wrapper;
|
||||
- `scripts/export_kb_transition_replay_material.py`: read-only applied-transition
|
||||
exporter;
|
||||
- `ops/build_v3_genesis_ledger_bundle.py`: V3-only ordered ledger assembler;
|
||||
- `scripts/kb_apply_prereqs.sql`: roles, immutable approval row, gate functions,
|
||||
ownership, indexes, and ACLs;
|
||||
- `scripts/run_approve_claim_isolated_container_canary.sh`: disposable
|
||||
lifecycle plus optional live-readonly source/service checks.
|
||||
|
||||
## Authority Invariants
|
||||
|
||||
1. `kb_review` and `kb_apply` are separate `NOINHERIT` login roles.
|
||||
2. `kb_gate_owner` is `NOLOGIN` and owns immutable approval/gate functions.
|
||||
3. Review binds DB role, reviewer, type, full payload, timestamp, and note.
|
||||
4. Apply locks and compares that snapshot before canonical writes.
|
||||
5. Payload-controlled fields and exact table deltas are verified.
|
||||
6. Existing evidence/edge semantic mismatches roll back the transaction.
|
||||
7. Replay, stale payload, source-hash drift, and direct privilege probes fail.
|
||||
8. `kb_apply` remains a trusted operator-only canonical writer, not autonomous
|
||||
chat write authority.
|
||||
|
||||
## Operator Path
|
||||
|
||||
1. Read the proposal, source bytes/hash, and canonical target rows.
|
||||
2. Normalize rich intent; reject lossy, duplicate, or unsupported mappings.
|
||||
3. Run focused tests:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest -q \
|
||||
tests/test_kb_proposal_normalize.py \
|
||||
tests/test_approve_proposal.py \
|
||||
tests/test_apply_proposal.py \
|
||||
tests/test_kb_apply_prereqs.py
|
||||
```
|
||||
|
||||
4. Run the generic and real-packet disposable canaries.
|
||||
5. Require exact before/after projections, deltas, source hashes, approval row,
|
||||
applied timestamp, service state, rollback, and cleanup.
|
||||
6. Treat source checkout deployment, permission migration, worker enablement,
|
||||
and production proposal application as separate windows.
|
||||
|
||||
## Current Proof - 2026-07-12
|
||||
|
||||
- Deterministic source composition passes all `13` checks and links one source,
|
||||
one extracted claim, one evidence row, and one `pending_review` proposal. It
|
||||
proves the retained fixture path, not arbitrary-source model extraction.
|
||||
Arbitrary production document/tweet ingestion is not proven.
|
||||
- The isolated approved-change lifecycle creates exact deltas of `2` claims,
|
||||
`2` sources, `2` evidence rows, `1` edge, and `1` reasoning tool, stamps the
|
||||
proposal applied, rolls back, and leaves no container or volume.
|
||||
- The earlier generic and Helmer v3 source-bound receipts remain `37/37`; the
|
||||
newer lifecycle receipt adds stricter service/source isolation and cleanup.
|
||||
- A VPS live-readonly T3 clone run passed with unchanged canonical counts and
|
||||
unchanged gateway PID. Production was not applied.
|
||||
- The broader live-VPS full-data source-composition checkpoint passes `34/34`:
|
||||
new hash-bound document/post, conflicting atomic claims, exact source/evidence
|
||||
links, strict proposal, separated approval/apply, restarted discovery, graph
|
||||
reasoning, and cleanup. This is isolated clone proof, not Telegram delivery
|
||||
or production mutation.
|
||||
- PR #86 merged the harness/operator fixes. VPS auto-deploy synchronized source
|
||||
without restarting Leo; the gateway remained PID `2403328`, `NRestarts=0`.
|
||||
- Current VPS canonical counts remain claims `1837`, sources `4145`, evidence
|
||||
`4670`, edges `4916`, proposals `26`.
|
||||
|
||||
Read:
|
||||
|
||||
- `docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.json`
|
||||
- `docs/reports/leo-working-state-20260709/leo-source-composition-clone-checkpoint-current.json`
|
||||
- `docs/reports/leo-working-state-20260709/leo-v3-document-source-lifecycle-current.md`
|
||||
- `docs/reports/leo-working-state-20260709/source-document-compiler-canary-20260713.md`
|
||||
|
||||
## Claim Ceiling
|
||||
|
||||
Leo has strong isolated proof for source-bound composition, guarded canonical
|
||||
application, restarted discovery, and graph reasoning. Production still has
|
||||
three approved-but-unapplied legacy packets and fourteen pending-review
|
||||
proposals. No broad autonomous production apply authority exists, and no
|
||||
production rich packet should be applied without exact retained authorization,
|
||||
operator identity, postflight, regression, rollback, and cleanup evidence.
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
---
|
||||
name: teleo-leo-onboarding
|
||||
description: Use when a worker needs fast context on Teleo/Living IP, Leo architecture, VPS/GCP and Hermes runtime boundaries, database provenance, ingestion/apply/reconstruction, identity, testing, security, secrets, or incident recovery before doing Leo or Teleo work.
|
||||
---
|
||||
|
||||
# Teleo / Leo Onboarding
|
||||
|
||||
## Job
|
||||
|
||||
Orient the worker before action. Build a current, proof-linked understanding of
|
||||
the company/product, Leo's role, the infrastructure surfaces, and the exact
|
||||
claim ceiling without rediscovering the system from historical chat.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "onboard to Leo"
|
||||
- "what is Teleo / Living IP"
|
||||
- "load Leo VPS context"
|
||||
- "Fable handoff for Leo"
|
||||
- "before working on Teleo infra"
|
||||
- "explain the architecture"
|
||||
- "recover or reconstruct Leo"
|
||||
- "which skill should I use"
|
||||
|
||||
## Core Model
|
||||
|
||||
- Teleo is the knowledge/agent infrastructure layer behind Leo.
|
||||
- Leo is the operator-facing agent expected to answer in Telegram, remember operator context, reason from canonical KB state, stage concrete KB changes, and support approved changes becoming canonical DB rows with proof.
|
||||
- The immediate July 9 issue is not generic bot liveness. It is m3taversal's expectation that approved KB changes move beyond proposal state when appropriate.
|
||||
- The VPS is the currently proven Telegram-visible Leo surface.
|
||||
- GCP is a separate lane. Direct passwordless SSH and `sudo -n` work as of
|
||||
2026-07-14. The newest VPS DB was restored to a disposable private Cloud SQL
|
||||
clone with exact parity, and a real ID-free no-send reasoning turn passed.
|
||||
The merged helper/skill were deployed, supported KB commands now fail closed
|
||||
to Cloud SQL, and the service survived a controlled restart with live
|
||||
post-restart status/search receipts. Persistent GCP `teleo_canonical` is still
|
||||
an older staging copy; Telegram delivery, GCP canonical mutation, ongoing
|
||||
replication, promotion, and cutover remain separate proof rows.
|
||||
|
||||
## Architecture Boundaries
|
||||
|
||||
- VPS canonical KB: Docker Postgres `teleo-pg`, database `teleo`.
|
||||
- GCP canonical KB: private Cloud SQL database `teleo_canonical`.
|
||||
- Canonical rows: `public.claims`, `public.sources`,
|
||||
`public.claim_evidence`, `public.claim_edges`, and related identity/strategy
|
||||
tables; staging/review is `kb_stage.*`.
|
||||
- Hermes `state.db` and session JSONL files are conversation continuity, not
|
||||
canonical collective knowledge. GCP memory-sync success does not prove the
|
||||
canonical KB was restored or updated.
|
||||
- `SOUL.md` is a rendered/runtime artifact. Direct file edits are not canonical
|
||||
identity changes without DB row and render/sync proof.
|
||||
|
||||
## Clean-Context First Commands
|
||||
|
||||
From the repository root, validate the pack without a virtual environment or
|
||||
network access:
|
||||
|
||||
```bash
|
||||
.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .
|
||||
```
|
||||
|
||||
Install exactly the manifest-indexed skills into an empty temporary agent skill
|
||||
root with:
|
||||
|
||||
```bash
|
||||
.agents/skills/teleo-leo-onboarding/scripts/install_skill_pack.py \
|
||||
--root . \
|
||||
--target /private/tmp/teleo-clean-agent/skills
|
||||
```
|
||||
|
||||
Installed skills still resolve repository-relative routes against the current
|
||||
`teleo-infrastructure` checkout. Keep the agent working directory at the repo
|
||||
root. For pytest, use `.venv/bin/python`; if `.venv` is absent, create it from
|
||||
`README.md` before running tests. Do not guess a bare `python` command.
|
||||
|
||||
Run the deterministic isolated acceptance canary with:
|
||||
|
||||
```bash
|
||||
.agents/skills/teleo-leo-onboarding/scripts/run_clean_context_canary.py \
|
||||
--root . \
|
||||
--output docs/reports/leo-working-state-20260709/skill-pack-clean-context-canary-current.json
|
||||
```
|
||||
|
||||
## Task Router
|
||||
|
||||
| Area | Skill | Canonical first route |
|
||||
|---|---|---|
|
||||
| Company, product, architecture | `teleo-leo-onboarding` | `docs/reports/leo-working-state-20260709/current-truth-index.md` |
|
||||
| VPS runtime and incidents | `teleo-vps-runtime-ops` | Fresh service, deploy, DB, and cleanup readbacks |
|
||||
| GCP and Cloud SQL | `teleo-gcp-parity-ops` | Current main evidence; keep open PR #148 candidate-only |
|
||||
| Hermes packaging/runtime | `nousresearch-hermes-agent` | `hermes-agent/` plus the live service-specific ops skill |
|
||||
| Database provenance | `teleo-infra-provenance` and `teleo-db-operator` | Separate Git, runtime, SQLite, Postgres, and retained proof |
|
||||
| Local corpus inventory and extraction preparation | `teleo-corpus-knowledge-rebuild` | Exact UTF-8 snapshots and section work items; semantic claims are a separate reviewed step |
|
||||
| Source ingestion and proposal/apply | `teleo-kb-db-change-workflow` | Hash-bound source, review, guarded apply, receipt |
|
||||
| Reconstruction and recovery | `teleo-reconstruction-recovery` | `docs/kb-rebuild-and-recompile.md` |
|
||||
| Identity and rendered soul | `working-leo-m3taversal-outcomes` | Canonical identity rows before `SOUL.md` |
|
||||
| Testing and proof tiers | `teleo-proof-handoff` | Exact tier, command, receipt, cleanup, claim ceiling |
|
||||
| Secrets | `private-password-storage` | Provider login first; never paste or retrieve a secret |
|
||||
|
||||
## Read First
|
||||
|
||||
From the repo root, read:
|
||||
|
||||
1. `docs/reports/leo-working-state-20260709/current-truth-index.md`
|
||||
2. `docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md`
|
||||
3. `docs/reports/leo-working-state-20260709/gcp-db-first-parity-current.json`
|
||||
4. `docs/reports/leo-working-state-20260709/gcp-db-first-blind-claim-current.json`
|
||||
5. `docs/reports/leo-working-state-20260709/gcp-db-first-cleanup-current.json`
|
||||
6. `docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json`
|
||||
7. `docs/reports/leo-working-state-20260709/operator-surface-map.md`
|
||||
8. `docs/reports/leo-working-state-20260709/working-leo-definition-20260709.md`
|
||||
9. `.agents/skills/working-leo-m3taversal-outcomes/SKILL.md`
|
||||
10. `docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.md`
|
||||
11. `docs/reports/leo-working-state-20260709/gcp-cloud-sql-t3-live-readonly-current.md`
|
||||
12. `docs/kb-rebuild-and-recompile.md`
|
||||
|
||||
## Required Status Split
|
||||
|
||||
Every status answer must split:
|
||||
|
||||
- `VPS runtime`
|
||||
- `Telegram-visible Leo`
|
||||
- `KB proposal/staging`
|
||||
- `Canonical DB apply`
|
||||
- `GCP parity`
|
||||
- `Runtime/code provenance`
|
||||
|
||||
Do not collapse GCP demo readiness into Telegram completion. Do not collapse proposal approval into canonical DB application.
|
||||
|
||||
## Hard Boundaries
|
||||
|
||||
- Do not do paid route work or introduce paid-route naming.
|
||||
- Do not change live VPS Leo runtime behavior unless explicitly authorized.
|
||||
- Do not production-apply DB packets unless explicitly authorized.
|
||||
- Do not expose secret contents.
|
||||
- Do not treat an old summary as current if a fresh readback is cheap.
|
||||
- PRs #146 and #147 are merged repository truth. PR #148 remains an open GCP
|
||||
least-privilege candidate until live `gh pr view 148` readback says otherwise;
|
||||
never route a deployment from its branch as though it were `main`.
|
||||
- Use `ssh -o BatchMode=yes teleo-gcp-staging true` to preflight passwordless
|
||||
GCP VM access. If the firewall route drifts, rediscover the authenticated
|
||||
non-secret route before declaring a blocker. The OIDC/IAP workflow is still
|
||||
not bootstrapped. Do not print the Cloud SQL or Google password or create
|
||||
IAM/users inside a read-only lane.
|
||||
- A benchmark score is not sufficient when an answer contradicts the DB receipt.
|
||||
Compare every printed count to `teleo-kb status` or the canonical manifest.
|
||||
|
||||
## Output Format
|
||||
|
||||
For onboarding handoffs, return:
|
||||
|
||||
1. Product/architecture summary in 8 bullets or fewer.
|
||||
2. Current proof split by VPS, Telegram, DB, GCP.
|
||||
3. Evidence files actually read.
|
||||
4. Exact claim ceiling.
|
||||
5. Next runnable non-production action.
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install the manifest-indexed Leo/Teleo skills into an empty skill root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MANIFEST = Path("docs/reports/leo-working-state-20260709/skill-pack-manifest.json")
|
||||
SKILL_NAME = re.compile(r"[a-z0-9-]{1,64}")
|
||||
|
||||
|
||||
def _tracked_skill_files(root: Path, skill_dir: Path) -> list[Path]:
|
||||
relative_dir = skill_dir.relative_to(root)
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "ls-files", "-z", "--", str(relative_dir)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="replace"))
|
||||
files = [root / Path(item.decode("utf-8")) for item in result.stdout.split(b"\0") if item]
|
||||
if not files:
|
||||
raise ValueError(f"skill has no tracked files: {relative_dir}")
|
||||
for source in files:
|
||||
if source.is_symlink():
|
||||
raise ValueError(f"skill contains a symlink: {source.relative_to(root)}")
|
||||
if not source.is_file():
|
||||
raise ValueError(f"skill route is not a regular file: {source.relative_to(root)}")
|
||||
source.resolve().relative_to(skill_dir.resolve())
|
||||
return files
|
||||
|
||||
|
||||
def install(root: Path, manifest_path: Path, target: Path) -> dict[str, object]:
|
||||
manifest_file = root / manifest_path
|
||||
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
skills = manifest["skills"]
|
||||
if target.exists():
|
||||
raise ValueError(f"target already exists: {target}")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=".teleo-skill-pack-", dir=target.parent))
|
||||
installed: list[str] = []
|
||||
try:
|
||||
for entry in skills:
|
||||
name = entry["name"]
|
||||
if not isinstance(name, str) or SKILL_NAME.fullmatch(name) is None:
|
||||
raise ValueError(f"invalid skill name: {name!r}")
|
||||
expected_path = Path(".agents") / "skills" / name / "SKILL.md"
|
||||
if Path(entry["path"]) != expected_path:
|
||||
raise ValueError(f"skill path must be {expected_path}: {entry['path']!r}")
|
||||
if name in installed:
|
||||
raise ValueError(f"duplicate skill name: {name}")
|
||||
|
||||
source = root / expected_path.parent
|
||||
source.resolve().relative_to((root / ".agents" / "skills").resolve())
|
||||
if not source.is_dir():
|
||||
raise FileNotFoundError(source)
|
||||
destination = stage / name
|
||||
destination.resolve().relative_to(stage.resolve())
|
||||
for tracked_source in _tracked_skill_files(root, source):
|
||||
relative = tracked_source.relative_to(source)
|
||||
tracked_destination = destination / relative
|
||||
tracked_destination.resolve().relative_to(destination.resolve())
|
||||
tracked_destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(tracked_source, tracked_destination)
|
||||
installed.append(name)
|
||||
stage.rename(target)
|
||||
except Exception:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return {
|
||||
"artifact": "leo_teleo_skill_pack_install",
|
||||
"status": "pass",
|
||||
"installed_skill_count": len(installed),
|
||||
"installed_skills": installed,
|
||||
"contains_secrets": False,
|
||||
"production_mutation_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
parser.add_argument("--target", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = install(args.root.resolve(), args.manifest, args.target.resolve())
|
||||
except Exception as exc:
|
||||
result = {"artifact": "leo_teleo_skill_pack_install", "status": "fail", "error": str(exc)}
|
||||
sys.stdout.write(json.dumps(result, indent=2, sort_keys=True) + "\n")
|
||||
return 1
|
||||
sys.stdout.write(json.dumps(result, indent=2, sort_keys=True) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run the deterministic T2 isolated-install acceptance canary for the skill pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MANIFEST = Path("docs/reports/leo-working-state-20260709/skill-pack-manifest.json")
|
||||
|
||||
SEMANTIC_CONTRACT = {
|
||||
"authority": (
|
||||
"teleo-infra-provenance",
|
||||
"Canonical code/deployment repository: GitHub `living-ip/teleo-infrastructure`.",
|
||||
"GitHub living-ip/teleo-infrastructure",
|
||||
),
|
||||
"vps_database": (
|
||||
"teleo-leo-onboarding",
|
||||
"VPS canonical KB: Docker Postgres `teleo-pg`, database `teleo`.",
|
||||
"teleo-pg / teleo",
|
||||
),
|
||||
"gcp_database": (
|
||||
"teleo-leo-onboarding",
|
||||
"GCP canonical KB: private Cloud SQL database `teleo_canonical`.",
|
||||
"private Cloud SQL teleo_canonical; persistent copy remains staging",
|
||||
),
|
||||
"live_service": (
|
||||
"teleo-infra-provenance",
|
||||
"Live service: `leoclean-gateway.service`",
|
||||
"leoclean-gateway.service",
|
||||
),
|
||||
"merged_reconstruction": (
|
||||
"teleo-reconstruction-recovery",
|
||||
"PR #146 is merged",
|
||||
"PR #146 merged deterministic genesis-plus-ledger reconstruction",
|
||||
),
|
||||
"merged_reasoning_verifier": (
|
||||
"teleo-reconstruction-recovery",
|
||||
"PR #147 is merged",
|
||||
"PR #147 merged unseen-reasoning verifier hardening",
|
||||
),
|
||||
"candidate_only_gcp": (
|
||||
"teleo-reconstruction-recovery",
|
||||
"PR #148 is a separate, open GCP least-privilege candidate",
|
||||
"PR #148 open candidate only; not canonical main",
|
||||
),
|
||||
"local_corpus_source_inventory": (
|
||||
"teleo-corpus-knowledge-rebuild",
|
||||
"It does not perform semantic claim extraction.",
|
||||
"deterministic source snapshots and extraction work items; no claims, proposals, or DB writes",
|
||||
),
|
||||
"next_safe_action": (
|
||||
"teleo-leo-onboarding",
|
||||
".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .",
|
||||
"run the repo-local path validator before any operational action",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, check=False, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _contains_contract(text: str, required_text: str) -> bool:
|
||||
"""Compare prose semantically across ordinary Markdown line wrapping."""
|
||||
|
||||
return " ".join(required_text.split()) in " ".join(text.split())
|
||||
|
||||
|
||||
def run_canary(root: Path, manifest_path: Path) -> dict[str, object]:
|
||||
manifest = json.loads((root / manifest_path).read_text(encoding="utf-8"))
|
||||
installer = root / manifest["installer"]
|
||||
selected_skills = sorted({contract[0] for contract in SEMANTIC_CONTRACT.values()})
|
||||
result: dict[str, object]
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="teleo-clean-context-") as temporary_text:
|
||||
temporary = Path(temporary_text)
|
||||
installed_root = temporary / "skills"
|
||||
install_process = _run(
|
||||
[str(installer), "--root", str(root), "--manifest", str(manifest_path), "--target", str(installed_root)]
|
||||
)
|
||||
try:
|
||||
install_receipt = json.loads(install_process.stdout)
|
||||
except json.JSONDecodeError:
|
||||
install_receipt = {"status": "fail", "error": install_process.stdout or install_process.stderr}
|
||||
|
||||
semantic_checks: dict[str, bool] = {}
|
||||
answers: dict[str, str] = {}
|
||||
evidence_paths: dict[str, str] = {}
|
||||
for key, (skill_name, required_text, answer) in SEMANTIC_CONTRACT.items():
|
||||
installed_skill = installed_root / skill_name / "SKILL.md"
|
||||
text = installed_skill.read_text(encoding="utf-8") if installed_skill.is_file() else ""
|
||||
semantic_checks[key] = _contains_contract(text, required_text)
|
||||
answers[key] = answer
|
||||
evidence_paths[key] = f".agents/skills/{skill_name}/SKILL.md"
|
||||
|
||||
installed_validator = installed_root / "teleo-leo-onboarding" / "scripts" / "validate_skill_pack.py"
|
||||
validator_process = _run([str(installed_validator), "--root", str(root), "--manifest", str(manifest_path)])
|
||||
try:
|
||||
validator_receipt = json.loads(validator_process.stdout)
|
||||
except json.JSONDecodeError:
|
||||
validator_receipt = {"status": "fail", "error": validator_process.stdout or validator_process.stderr}
|
||||
|
||||
problems: list[dict[str, str]] = []
|
||||
if install_process.returncode != 0 or install_receipt.get("status") != "pass":
|
||||
problems.append({"kind": "install_failed", "detail": str(install_receipt.get("error", "unknown"))})
|
||||
if validator_process.returncode != 0 or validator_receipt.get("status") != "pass":
|
||||
problems.append({"kind": "path_validation_failed", "detail": str(validator_receipt.get("problems", []))})
|
||||
for key, passed in semantic_checks.items():
|
||||
if not passed:
|
||||
problems.append({"kind": "semantic_route_missing", "detail": key})
|
||||
|
||||
passed = not problems
|
||||
result = {
|
||||
"artifact": "leo_teleo_skill_pack_isolated_install_canary",
|
||||
"status": "pass" if passed else "fail",
|
||||
"required_tier": "T2_runtime",
|
||||
"current_tier": "T2_runtime" if passed else "T1_model",
|
||||
"proof_scope": "deterministic_isolated_install_and_route_validation",
|
||||
"fresh_temporary_skill_root": True,
|
||||
"ambient_skill_root_used": False,
|
||||
"agent_reasoning_exercised": False,
|
||||
"installed_skill_count": install_receipt.get("installed_skill_count", 0),
|
||||
"selected_installed_skills": selected_skills,
|
||||
"semantic_checks": semantic_checks,
|
||||
"expected_answers": answers,
|
||||
"evidence_paths": evidence_paths,
|
||||
"canary_command": ".agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .",
|
||||
"canary_exit_status": validator_process.returncode,
|
||||
"canary_receipt": validator_receipt,
|
||||
"missing_to_reach_required_tier": [] if passed else problems,
|
||||
"strongest_claim_allowed": (
|
||||
"T2 deterministic isolated local skill install, route-contract, and path validation only; this artifact "
|
||||
"does not claim an agent reasoning run, and retained product proofs keep their own VPS, GCP-staging, "
|
||||
"isolated-clone, Telegram, and production claim ceilings"
|
||||
),
|
||||
"contains_secrets": False,
|
||||
"production_mutation_authorized": False,
|
||||
"external_runtime_contacted": False,
|
||||
"problems": problems,
|
||||
}
|
||||
|
||||
result["cleanup_readback"] = {
|
||||
"temporary_skill_root_removed": not Path(temporary_text).exists(),
|
||||
"orphan_processes_started": False,
|
||||
}
|
||||
if not result["cleanup_readback"]["temporary_skill_root_removed"]:
|
||||
result["status"] = "fail"
|
||||
result["current_tier"] = "T1_model"
|
||||
result["problems"].append({"kind": "cleanup_failed", "detail": "temporary skill root remains"})
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = args.root.resolve()
|
||||
result = run_canary(root, args.manifest)
|
||||
payload = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||
if args.output:
|
||||
output = args.output if args.output.is_absolute() else root / args.output
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(payload, encoding="utf-8")
|
||||
sys.stdout.write(payload)
|
||||
return 0 if result["status"] == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate the repo-native Leo/Teleo skill pack and its local routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MANIFEST = Path("docs/reports/leo-working-state-20260709/skill-pack-manifest.json")
|
||||
REPO_PREFIXES = (
|
||||
".agents/",
|
||||
".github/",
|
||||
"deploy/",
|
||||
"docs/",
|
||||
"fixtures/",
|
||||
"hermes-agent/",
|
||||
"lib/",
|
||||
"ops/",
|
||||
"outputs/",
|
||||
"schemas/",
|
||||
"scripts/",
|
||||
"systemd/",
|
||||
"tests/",
|
||||
)
|
||||
PATH_SUFFIXES = (".json", ".jsonl", ".md", ".py", ".sh", ".sql", ".yaml", ".yml")
|
||||
CODE_SPAN = re.compile(r"`([^`\n]+)`")
|
||||
MARKDOWN_LINK = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
|
||||
FENCED_SHELL = re.compile(r"```(?:bash|sh|shell)\n(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
def _frontmatter(text: str) -> dict[str, str]:
|
||||
if not text.startswith("---\n"):
|
||||
return {}
|
||||
try:
|
||||
block = text.split("---\n", 2)[1]
|
||||
except IndexError:
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
for line in block.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
values[key.strip()] = value.strip().strip('"')
|
||||
return values
|
||||
|
||||
|
||||
def _tracked_paths(root: Path) -> set[Path]:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "ls-files", "-z"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="replace"))
|
||||
return {Path(item.decode("utf-8")) for item in result.stdout.split(b"\0") if item}
|
||||
|
||||
|
||||
def _is_tracked(root: Path, path: Path, tracked: set[Path]) -> bool:
|
||||
relative = path.resolve().relative_to(root.resolve())
|
||||
if path.is_file():
|
||||
return relative in tracked
|
||||
prefix = f"{relative.as_posix().rstrip('/')}/"
|
||||
return any(item.as_posix().startswith(prefix) for item in tracked)
|
||||
|
||||
|
||||
def _clean_token(token: str) -> str:
|
||||
token = token.strip().strip("'\"")
|
||||
token = token.rstrip(",;.)]")
|
||||
token = re.sub(r":\d+$", "", token)
|
||||
return token
|
||||
|
||||
|
||||
def _candidate_tokens(span: str) -> list[str]:
|
||||
try:
|
||||
tokens = shlex.split(span)
|
||||
except ValueError:
|
||||
tokens = span.split()
|
||||
return [_clean_token(token) for token in tokens]
|
||||
|
||||
|
||||
def _resolve_candidate(root: Path, source: Path, token: str) -> Path | None:
|
||||
if not token or token in {".md", ".json", ".py", ".sh", ".sql"}:
|
||||
return None
|
||||
if any(marker in token for marker in ("*", "<", ">", "...", "${", "$LEDGER")):
|
||||
return None
|
||||
if token.startswith(("http://", "https://", "/", "~/")):
|
||||
return None
|
||||
if token.startswith(REPO_PREFIXES) or token in {"README.md", "CODEOWNERS"}:
|
||||
candidate = root / token
|
||||
elif token.endswith(PATH_SUFFIXES) and "/" in token:
|
||||
candidate = source.parent / token
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
candidate.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _inline_paths(root: Path, source: Path) -> set[Path]:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
candidates: set[Path] = set()
|
||||
spans = CODE_SPAN.findall(text) + MARKDOWN_LINK.findall(text)
|
||||
for block in FENCED_SHELL.findall(text):
|
||||
spans.extend(line for line in block.splitlines() if line.strip())
|
||||
for span in spans:
|
||||
for token in _candidate_tokens(span):
|
||||
candidate = _resolve_candidate(root, source, token)
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def validate(root: Path, manifest_path: Path) -> dict[str, object]:
|
||||
problems: list[dict[str, str]] = []
|
||||
checked: set[Path] = set()
|
||||
manifest_file = root / manifest_path
|
||||
if not manifest_file.is_file():
|
||||
return {
|
||||
"artifact": "leo_teleo_skill_pack_path_validation",
|
||||
"status": "fail",
|
||||
"problems": [{"kind": "missing_manifest", "path": str(manifest_path)}],
|
||||
}
|
||||
|
||||
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
tracked = _tracked_paths(root)
|
||||
if not _is_tracked(root, manifest_file, tracked):
|
||||
problems.append({"kind": "untracked_manifest", "path": str(manifest_path)})
|
||||
skills = manifest.get("skills", [])
|
||||
skill_names = {entry.get("name") for entry in skills}
|
||||
|
||||
for entry in skills:
|
||||
relative = Path(entry["path"])
|
||||
path = root / relative
|
||||
checked.add(path)
|
||||
if not path.is_file():
|
||||
problems.append({"kind": "missing_skill", "path": str(relative)})
|
||||
continue
|
||||
if not _is_tracked(root, path, tracked):
|
||||
problems.append({"kind": "untracked_skill", "path": str(relative)})
|
||||
metadata = _frontmatter(path.read_text(encoding="utf-8"))
|
||||
if metadata.get("name") != entry.get("name"):
|
||||
problems.append({"kind": "skill_name_mismatch", "path": str(relative)})
|
||||
if not metadata.get("description"):
|
||||
problems.append({"kind": "missing_skill_description", "path": str(relative)})
|
||||
if set(metadata) != {"name", "description"}:
|
||||
problems.append({"kind": "invalid_skill_frontmatter_keys", "path": str(relative)})
|
||||
if not re.fullmatch(r"[a-z0-9-]{1,64}", metadata.get("name", "")):
|
||||
problems.append({"kind": "invalid_skill_name", "path": str(relative)})
|
||||
if relative.parent.name != metadata.get("name"):
|
||||
problems.append({"kind": "skill_folder_name_mismatch", "path": str(relative)})
|
||||
|
||||
required_coverage = manifest.get("required_coverage", {})
|
||||
for area, owners in required_coverage.items():
|
||||
for owner in owners:
|
||||
if owner not in skill_names:
|
||||
problems.append({"kind": "unknown_coverage_owner", "path": f"{area}:{owner}"})
|
||||
|
||||
reference_paths = list(manifest.get("reference_files", []))
|
||||
command_paths = list(manifest.get("command_files", []))
|
||||
scan_paths = list(manifest.get("scan_files", []))
|
||||
for relative_text in reference_paths + command_paths + scan_paths:
|
||||
relative = Path(relative_text)
|
||||
path = root / relative
|
||||
checked.add(path)
|
||||
if not path.is_file():
|
||||
problems.append({"kind": "missing_manifest_path", "path": str(relative)})
|
||||
elif not _is_tracked(root, path, tracked):
|
||||
problems.append({"kind": "untracked_manifest_path", "path": str(relative)})
|
||||
|
||||
for relative_text in manifest.get("executable_files", []):
|
||||
relative = Path(relative_text)
|
||||
path = root / relative
|
||||
checked.add(path)
|
||||
if not path.is_file():
|
||||
problems.append({"kind": "missing_executable", "path": str(relative)})
|
||||
elif not os.access(path, os.X_OK):
|
||||
problems.append({"kind": "not_executable", "path": str(relative)})
|
||||
|
||||
scan_sources = [root / entry["path"] for entry in skills]
|
||||
scan_sources += [root / relative for relative in manifest.get("scan_files", [])]
|
||||
for source in scan_sources:
|
||||
if not source.is_file():
|
||||
continue
|
||||
for candidate in _inline_paths(root, source):
|
||||
checked.add(candidate)
|
||||
if not candidate.exists():
|
||||
problems.append(
|
||||
{
|
||||
"kind": "missing_inline_path",
|
||||
"path": str(candidate.resolve().relative_to(root.resolve())),
|
||||
"source": str(source.relative_to(root)),
|
||||
}
|
||||
)
|
||||
elif not _is_tracked(root, candidate, tracked):
|
||||
problems.append(
|
||||
{
|
||||
"kind": "untracked_inline_path",
|
||||
"path": str(candidate.resolve().relative_to(root.resolve())),
|
||||
"source": str(source.relative_to(root)),
|
||||
}
|
||||
)
|
||||
|
||||
relative_paths = sorted(str(path.resolve().relative_to(root.resolve())) for path in checked)
|
||||
digest = hashlib.sha256("\n".join(relative_paths).encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"artifact": "leo_teleo_skill_pack_path_validation",
|
||||
"required_tier": "T2_runtime",
|
||||
"status": "pass" if not problems else "fail",
|
||||
"manifest": str(manifest_path),
|
||||
"skill_count": len(skills),
|
||||
"coverage_area_count": len(required_coverage),
|
||||
"checked_path_count": len(relative_paths),
|
||||
"checked_paths_sha256": digest,
|
||||
"contains_secrets": False,
|
||||
"production_mutation_authorized": False,
|
||||
"problems": problems,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = args.root.resolve()
|
||||
result = validate(root, args.manifest)
|
||||
payload = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||
if args.output:
|
||||
output = args.output if args.output.is_absolute() else root / args.output
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(payload, encoding="utf-8")
|
||||
sys.stdout.write(payload)
|
||||
return 0 if result["status"] == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
---
|
||||
name: teleo-proof-handoff
|
||||
description: Use to package Leo/Teleo evidence and prompts for Fable, Codex, Opus, or future workers so they can continue without rereading the whole thread.
|
||||
---
|
||||
|
||||
# Teleo Proof Handoff
|
||||
|
||||
## Job
|
||||
|
||||
Create a compact, evidence-indexed handoff that preserves current truth, claim ceilings, exact blockers, and next executable actions.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "handoff to Fable"
|
||||
- "worker onboarding"
|
||||
- "pack the evidence"
|
||||
- "resume Leo work"
|
||||
- "create continuation prompt"
|
||||
- "what should the next agent know"
|
||||
|
||||
## Required Sections
|
||||
|
||||
1. `current_canary`: the next runnable action and expected result.
|
||||
2. `claim_ceiling`: what cannot be claimed yet.
|
||||
3. `proven`: exact proof bullets with paths.
|
||||
4. `not_proven`: exact remaining gaps.
|
||||
5. `do_not_do`: scope boundaries.
|
||||
6. `files_to_read_first`: no more than 10 files.
|
||||
7. `commands_to_rerun`: freshness checks.
|
||||
8. `next_action`: one non-production action to start now.
|
||||
|
||||
## Blocker Rule
|
||||
|
||||
If a worker claims blocked, require:
|
||||
|
||||
- `current_canary`
|
||||
- `attempted_routes`
|
||||
- `exact_gate`
|
||||
- `clear_CTA`
|
||||
- `next_non_user_action`
|
||||
|
||||
Do not accept vague blockers such as "login blocked", "human-origin proof needed", or "no access" without the exact app/account/route and next CTA.
|
||||
|
||||
## Fable Prompt
|
||||
|
||||
Use `docs/reports/leo-working-state-20260709/fable-leo-teleo-onboarding.md` as the default first prompt.
|
||||
|
||||
## Evidence Index
|
||||
|
||||
Use `docs/reports/leo-working-state-20260709/current-truth-index.md`.
|
||||
|
||||
## Handoff Claim Ceiling
|
||||
|
||||
A handoff is not completion. It is complete only if a future worker can start from retained files and run the named canary without asking for context.
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
---
|
||||
name: teleo-reconstruction-recovery
|
||||
description: Use when restoring, rebuilding, recompiling, or incident-recovering Leo's Teleo knowledge database, including snapshot recovery, genesis-plus-ledger replay, source recompilation, parity verification, and honest recovery claim ceilings.
|
||||
---
|
||||
|
||||
# Teleo Reconstruction Recovery
|
||||
|
||||
## Job
|
||||
|
||||
Choose the smallest recovery path that restores a usable Leo knowledge system
|
||||
without confusing snapshot restoration, strict ledger replay, and semantic
|
||||
source recompilation.
|
||||
|
||||
## Authority
|
||||
|
||||
Read `docs/kb-rebuild-and-recompile.md` first. It is the canonical recovery
|
||||
contract. Use `docs/reports/leo-working-state-20260709/current-truth-index.md`
|
||||
only for the newest retained proof point, and refresh live state before making a
|
||||
current VPS or GCP claim.
|
||||
|
||||
PR #146 is merged and owns the deterministic genesis-plus-ledger reconstruction
|
||||
slice in `ops/run_local_genesis_ledger_rebuild.py`. PR #147 is merged and owns
|
||||
the current unseen-reasoning verifier in
|
||||
`scripts/verify_leo_unseen_reasoning_chain.py`. PR #148 is a separate, open GCP
|
||||
least-privilege candidate; do not treat its branch files or proposed live state
|
||||
as canonical `main` recovery instructions until it merges.
|
||||
|
||||
## Select The Recovery Mode
|
||||
|
||||
| Need | Path | Honest result |
|
||||
|---|---|---|
|
||||
| Restore the latest verified state quickly | `ops/run_local_canonical_postgres_rebuild.py` | Exact snapshot recovery |
|
||||
| Snapshot a Forgejo/text corpus for extraction | `ops/run_local_corpus_knowledge_rebuild.py` | Content-addressed UTF-8 sources and work items; no claims |
|
||||
| Partition verified extraction work | `ops/shard_corpus_extraction_work.py` | Deterministic exact-once work shards; no source interpretation |
|
||||
| Prove strict post-genesis applies replay exactly | `ops/run_local_genesis_ledger_rebuild.py` | Isolated insert-only ledger replay |
|
||||
| Assemble reviewed V3 replay materials | `ops/build_v3_genesis_ledger_bundle.py` | Hash-pinned V3 ledger; optional operator-supplied admission metadata |
|
||||
| Turn one retained source into a reviewed packet | `scripts/compile_kb_source_packet.py` | Deterministic proposal packet, not canonical knowledge |
|
||||
| Rebuild every row from original sources | Follow the semantic recompilation contract in `docs/kb-rebuild-and-recompile.md` | Partial until every row and receipt is accounted for |
|
||||
| Diagnose a live incident before recovery | Use `.agents/skills/teleo-vps-runtime-ops/SKILL.md` and `.agents/skills/teleo-infra-provenance/SKILL.md` | Fresh read-only incident map |
|
||||
|
||||
## Safe Order
|
||||
|
||||
1. Identify the intended source database, snapshot, manifest, commit, and hashes.
|
||||
2. Keep private dumps, row payloads, source excerpts, and replay material outside
|
||||
the repository and mode `0600`.
|
||||
3. Inventory text sources before semantic extraction. For a binary original,
|
||||
retain its exact bytes and provide text from a separately pinned extractor;
|
||||
this repository does not yet supply a general PDF/office extractor.
|
||||
4. Build and independently verify the V3 ledger before running genesis replay.
|
||||
Admission metadata is operator-supplied and is not a cryptographic signature
|
||||
from `m3taversal`.
|
||||
5. Run the selected path in an isolated, network-disabled local container.
|
||||
6. Verify schema, constraints, roles, counts, row hashes, key queries, and exact
|
||||
cleanup.
|
||||
7. Run the relevant focused tests, then the unseen-reasoning verifier when the
|
||||
restored state is meant to support Leo answers.
|
||||
8. Separate local reconstruction from VPS restore, GCP restore, service restart,
|
||||
Telegram delivery, promotion, and production apply. Each is its own proof row.
|
||||
|
||||
## Commands
|
||||
|
||||
Use the repository virtual environment documented in `README.md`:
|
||||
|
||||
```bash
|
||||
.venv/bin/python ops/run_local_canonical_postgres_rebuild.py --help
|
||||
.venv/bin/python ops/run_local_corpus_knowledge_rebuild.py --help
|
||||
.venv/bin/python ops/shard_corpus_extraction_work.py --help
|
||||
.venv/bin/python ops/build_v3_genesis_ledger_bundle.py --help
|
||||
.venv/bin/python ops/run_local_genesis_ledger_rebuild.py --help
|
||||
.venv/bin/python scripts/compile_kb_source_packet.py --help
|
||||
.venv/bin/python -m pytest -q \
|
||||
tests/test_run_local_corpus_knowledge_rebuild.py \
|
||||
tests/test_shard_corpus_extraction_work.py \
|
||||
tests/test_v3_source_rebuild_lifecycle_postgres.py \
|
||||
tests/test_build_v3_genesis_ledger_bundle.py \
|
||||
tests/test_run_local_canonical_postgres_rebuild.py \
|
||||
tests/test_run_local_genesis_ledger_rebuild.py \
|
||||
tests/test_verify_leo_unseen_reasoning_chain.py
|
||||
```
|
||||
|
||||
The help and focused-test commands are safe local canaries. A real recovery
|
||||
requires caller-supplied private material and must retain a sanitized receipt
|
||||
without the material itself.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop before any live restore or restart when the source snapshot is not
|
||||
hash-bound, the source authority is ambiguous, parity fails, private material
|
||||
would enter Git or logs, cleanup cannot be proved, or the requested operation
|
||||
would promote GCP or mutate production without exact authorization.
|
||||
|
||||
## Claim Ceiling
|
||||
|
||||
- Exact snapshot recovery is working when its parity receipt passes.
|
||||
- Corpus inventory proves exact copied text bytes and extraction work locations,
|
||||
not semantic claims or binary extraction.
|
||||
- The V3 ledger builder accepts only exact source-normalized applied materials,
|
||||
selects every ordered entry, and never infers admission from proposal status.
|
||||
- The merged genesis-plus-ledger path proves an isolated insert-only slice.
|
||||
- Semantic source-to-blank-database recompilation remains incomplete until every
|
||||
canonical row traces to a genesis record or reviewed replay receipt.
|
||||
- No local receipt proves a live VPS/GCP restore, service restart, Telegram
|
||||
delivery, production apply, or GCP promotion.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
interface:
|
||||
display_name: "Teleo Reconstruction Recovery"
|
||||
short_description: "Recover and reconstruct Leo knowledge safely"
|
||||
default_prompt: "Use $teleo-reconstruction-recovery to choose and verify the safest Leo database recovery path."
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
---
|
||||
name: teleo-vps-runtime-ops
|
||||
description: Use for Leo VPS navigation, service health, Docker/Postgres readbacks, clone DB rehearsals, report sync, and runtime stability verification without changing live Leo behavior.
|
||||
---
|
||||
|
||||
# Teleo VPS Runtime Ops
|
||||
|
||||
## Job
|
||||
|
||||
Navigate and verify the VPS safely, with exact readbacks and no surprise live-runtime changes.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "Leo on VPS"
|
||||
- "check VPS stability"
|
||||
- "navigate Teleo VPS"
|
||||
- "leoclean service"
|
||||
- "run DB rehearsal on VPS"
|
||||
- "sync reports to VPS"
|
||||
|
||||
## Known Surfaces
|
||||
|
||||
- Host: `77.42.65.182`
|
||||
- SSH user: `root`
|
||||
- Local key path is available; never print private key contents.
|
||||
- Service: `leoclean-gateway.service`
|
||||
- DB container: `teleo-pg`
|
||||
- DB: `teleo`
|
||||
- Last retained count endpoints: claims `1837`, sources `4145`, evidence `4670`,
|
||||
edges `4916`, reasoning tools `17`, proposals `26`. Treat these as stale until
|
||||
refreshed.
|
||||
- Profile reports: `/home/teleo/.hermes/profiles/leoclean/kb_stage/reports/`
|
||||
- Deploy/source area: `/opt/teleo-eval/workspaces/deploy-infra`
|
||||
|
||||
## Required Fresh Readbacks
|
||||
|
||||
Before claiming runtime state, read:
|
||||
|
||||
```bash
|
||||
systemctl show leoclean-gateway.service -p ActiveState -p SubState -p MainPID -p NRestarts -p ExecMainStartTimestamp -p User -p WorkingDirectory
|
||||
```
|
||||
|
||||
Before claiming DB state, use `docker exec teleo-pg psql -U postgres -d teleo` and query exact tables/rows.
|
||||
Wrap verification queries in `begin transaction read only; ... rollback;`.
|
||||
|
||||
Before claiming cleanup, query disposable DBs:
|
||||
|
||||
```sql
|
||||
select datname from pg_database where datname like 'teleo%rehearsal%20260709' or datname like 'teleo%packet%20260709';
|
||||
```
|
||||
|
||||
## Auto-Deploy Semantics
|
||||
|
||||
`teleo-auto-deploy.timer` checks `main` every two minutes. A changed checkout
|
||||
HEAD or `.last-deploy-sha` does not by itself prove that Leo restarted or that
|
||||
canonical data changed. For each synchronized commit, also read:
|
||||
|
||||
1. `journalctl -u teleo-auto-deploy.service` for the exact deploy decision;
|
||||
2. the commit delta under `hermes-agent/leoclean-bin/` and
|
||||
`hermes-agent/leoclean-skills/vps/`;
|
||||
3. gateway PID, start timestamp, and restart count;
|
||||
4. `teleo-kb-apply-worker.service` enablement and active state;
|
||||
5. canonical count endpoints in a read-only transaction.
|
||||
|
||||
Treat source checkout sync, runtime profile sync, service restart, permission
|
||||
migration, worker enablement, and canonical DB apply as separate state changes.
|
||||
|
||||
## Mutating Boundaries
|
||||
|
||||
Allowed when needed:
|
||||
|
||||
- read-only service/DB inspection,
|
||||
- disposable clone DB creation and drop for rehearsal,
|
||||
- SQL rollback transactions,
|
||||
- syncing report artifacts to the report directory,
|
||||
- no-secret file checks.
|
||||
|
||||
Not allowed from this skill alone:
|
||||
|
||||
- restarting or changing the live Leo service,
|
||||
- changing live runtime config,
|
||||
- production DB commit/apply,
|
||||
- exposing secret contents,
|
||||
- deleting non-disposable data.
|
||||
|
||||
## Rehearsal Rules
|
||||
|
||||
For DB rehearsals:
|
||||
|
||||
1. Capture production preflight counts.
|
||||
2. Create a disposable DB from live state.
|
||||
3. Run commit SQL only in the disposable DB.
|
||||
4. Run postflight counts.
|
||||
5. Run delete rollback if applicable.
|
||||
6. Drop the disposable DB.
|
||||
7. Verify production counts stayed unchanged.
|
||||
8. Verify the disposable DB no longer exists.
|
||||
|
||||
For the guarded claim-bundle lifecycle, prefer the retained isolated wrapper:
|
||||
|
||||
```bash
|
||||
scripts/run_approve_claim_isolated_container_canary.sh \
|
||||
--output docs/reports/leo-working-state-20260709/approve-claim-clone-canary-current.json
|
||||
```
|
||||
|
||||
It must use a disposable unexposed PostgreSQL container, bind its receipt to
|
||||
current source hashes, compare exact payload projections and table deltas, read
|
||||
live count/service endpoints without writing them, and independently prove the
|
||||
container/workdir are absent afterward. Do not install the candidate code into
|
||||
the live Leo deploy merely to run this canary.
|
||||
|
||||
To prove that Leo itself can inspect one lifecycle state through the real
|
||||
`GatewayRunner` while remaining bound to a disposable full-data clone, run the
|
||||
checkpoint on the VPS as `teleo` while that clone exists:
|
||||
|
||||
```bash
|
||||
sudo -u teleo install -d -m 700 /home/teleo/leo-checkpoint-reports
|
||||
sudo -u teleo HOME=/home/teleo \
|
||||
/home/teleo/.hermes/hermes-agent/venv/bin/python \
|
||||
scripts/run_leo_clone_bound_handler_checkpoint.py \
|
||||
--container <disposable-container> \
|
||||
--db teleo \
|
||||
--prompt-id <stable-id> \
|
||||
--prompt "<m3taversal-style KB question>" \
|
||||
--expected-state approved \
|
||||
--copy-model-auth \
|
||||
--output /home/teleo/leo-checkpoint-reports/leo-clone-bound-checkpoint.json
|
||||
```
|
||||
|
||||
The supplied clone must have Docker label
|
||||
`com.livingip.leo.checkpoint=disposable`, network mode `none`, no published
|
||||
ports, no mount source shared with production, and a PostgreSQL system
|
||||
identifier distinct from production. The harness resolves the name once, pins
|
||||
all operations to the full container ID, and fails if the name is rebound.
|
||||
|
||||
`--copy-model-auth` copies only `auth.json` into a private UUID-scoped profile
|
||||
and binds only the configured provider's env-backed credential in memory. The
|
||||
receipt records provider/variable names, never credential values or hashes. The
|
||||
gateway runs in a dedicated process group with no delivery adapters and only
|
||||
`skills_list`, `skill_view`, and a terminal handler restricted to the temporary
|
||||
clone-bound wrapper's read-only verbs. The terminal subprocess must not inherit
|
||||
the model credential. A timeout must terminate the child process group before
|
||||
the profile is removed.
|
||||
|
||||
The checkpoint must observe at least one successful `teleo-kb` call bound to
|
||||
the supplied container/database, remove the profile, and prove production row
|
||||
counts plus row-content fingerprints, gateway state, and live bridge hashes are
|
||||
unchanged. Every model tool call must be in the allowlist and every KB call must
|
||||
complete successfully for a clean pass. A correct answer after a failed call
|
||||
and retry is recovered behavior, not a clean reliability pass. The checkpoint
|
||||
does not review, approve, apply, restart, post to Telegram, or mutate
|
||||
production.
|
||||
|
||||
After that read-only checkpoint is clean, prove the lower-level
|
||||
pending-to-canonical lifecycle in the same kind of disposable clone:
|
||||
|
||||
```bash
|
||||
sudo -u teleo HOME=/home/teleo \
|
||||
/home/teleo/.hermes/hermes-agent/venv/bin/python \
|
||||
scripts/run_leo_clone_lifecycle_checkpoint.py \
|
||||
--container <disposable-container> \
|
||||
--db teleo \
|
||||
--copy-model-auth \
|
||||
--operator-review \
|
||||
--guarded-apply \
|
||||
--output /home/teleo/leo-checkpoint-reports/leo-clone-lifecycle-checkpoint.json
|
||||
```
|
||||
|
||||
This command gives Leo only one deterministic staging verb. The harness, not
|
||||
Leo, owns review and guarded apply. A pass requires exact structured receipts
|
||||
for conversation memory, pending, approved-but-unapplied, and applied state;
|
||||
exact linked claim/source/evidence rows; an isolated handler reopen using the
|
||||
same persisted session; zero rejected or nonzero terminal calls; and unchanged
|
||||
production DB fingerprints, service state, and live bridge hashes. It is a
|
||||
mutation primitive, not proof that Leo can extract knowledge from arbitrary
|
||||
documents or tweets.
|
||||
|
||||
For the real source-composition checkpoint, first create a fresh full-data
|
||||
clone with the same disposable label/network/mount rules, then install the gate
|
||||
and separated ephemeral clone credentials into a private run directory:
|
||||
|
||||
```bash
|
||||
sudo -u teleo HOME=/home/teleo \
|
||||
/home/teleo/.hermes/hermes-agent/venv/bin/python \
|
||||
scripts/bootstrap_clone_kb_gate.py \
|
||||
--container <fresh-disposable-container> \
|
||||
--db teleo \
|
||||
--output-dir <private-run-directory>
|
||||
```
|
||||
|
||||
The bootstrap must verify both `kb_review` and `kb_apply` logins and emit only
|
||||
credential file paths, never secret values or hashes. Then run:
|
||||
|
||||
```bash
|
||||
sudo -u teleo HOME=/home/teleo \
|
||||
/home/teleo/.hermes/hermes-agent/venv/bin/python \
|
||||
scripts/run_leo_clone_composition_checkpoint.py \
|
||||
--container <fresh-disposable-container> \
|
||||
--db teleo \
|
||||
--copy-model-auth \
|
||||
--operator-review \
|
||||
--guarded-apply \
|
||||
--review-secrets-file <private-run-directory>/kb-review.env \
|
||||
--apply-secrets-file <private-run-directory>/kb-apply.env \
|
||||
--output <private-run-directory>/reports/composition-current.json
|
||||
```
|
||||
|
||||
A pass requires model-driven dedupe search and extraction from supplied source
|
||||
bytes, exact source hashes and excerpts, linked claims/evidence/conflict edges,
|
||||
no row change before staging, deterministic normalization, separated review and
|
||||
apply, exact clone deltas, a new handler process reasoning over exact canonical
|
||||
rows without supplied IDs, session-marker recall, nonce-bound tool receipts,
|
||||
unchanged production DB/service/bridge state, and complete clone/profile/secret
|
||||
cleanup. The retained current receipt is
|
||||
`docs/reports/leo-working-state-20260709/leo-source-composition-clone-checkpoint-current.json`.
|
||||
It is not Telegram-visible proof and does not authorize production apply.
|
||||
|
||||
## Blocker Format
|
||||
|
||||
Do not say "blocked" without:
|
||||
|
||||
- `current_canary`
|
||||
- `attempted_routes`
|
||||
- `exact_gate`
|
||||
- `clear_CTA`
|
||||
- `next_non_user_action`
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
---
|
||||
name: working-leo-m3taversal-outcomes
|
||||
description: "Use when defining, testing, or repairing Leo against m3taversal's expected outcomes: Telegram memory, critical reasoning, canonical KB truth, proposed-vs-approved-vs-applied state, and guarded DB manipulation."
|
||||
---
|
||||
|
||||
# Working Leo / m3taversal Outcomes
|
||||
|
||||
## Job
|
||||
|
||||
Keep Leo work anchored to what m3taversal appears to mean by "working": not just answers, but remembered context, truthful KB state, and approved concrete changes becoming canonical rows through a guarded proof path.
|
||||
|
||||
## Participant Identity Rule
|
||||
|
||||
- Address `@m3taversal` only as `m3taversal`, exactly.
|
||||
- Never infer, invent, shorten, or substitute a personal name from a session
|
||||
header, memory, identity document, another chat, or another participant.
|
||||
- Resolve the speaker from the current Telegram update. Do not transfer identity
|
||||
across users when someone replies, tags an account, or joins the thread.
|
||||
- The legacy database value `m3ta` may be reported only as a quoted stored
|
||||
reviewer handle. It is never a form of address.
|
||||
- Standard response labels are neutral. Use `Next proof-changing follow-up:`.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "working Leo"
|
||||
- "m3taversal expected outcomes"
|
||||
- "m3taversal says Leo is broken"
|
||||
- "able to manipulate the knowledge base"
|
||||
- "same state as last night"
|
||||
- "critical reasoning behavior"
|
||||
|
||||
## Definition Of Working
|
||||
|
||||
A working Leo is a Telegram-facing agent that:
|
||||
|
||||
1. Remembers the current operator conversation.
|
||||
2. Grounds KB answers in canonical Teleo Postgres rows when claiming KB truth.
|
||||
3. Distinguishes `proposed`, `pending_review`, `approved`, `applied`, and `not applied`.
|
||||
4. Does not say an approval changed canonical DB state when only `kb_stage` changed.
|
||||
5. Can answer vague, high-level m3taversal-style incident prompts without being spoon-fed exact proposal IDs.
|
||||
6. Can compose the KB from a previously unindexed document, URL, or tweet-like
|
||||
source: retain a byte/hash-bound source locator, extract atomic claims and
|
||||
exact evidence excerpts, preserve useful metadata, and link every claim to
|
||||
its evidence and source.
|
||||
7. Detects duplicates, conflicts, updates, and insufficient evidence before
|
||||
staging; uncertainty must remain visible instead of being normalized into a
|
||||
stronger claim.
|
||||
8. Can stage those concrete KB changes from Telegram with enough structure for
|
||||
human review, without making staged content canonical.
|
||||
9. Can move approved concrete changes through a guarded apply path when authorized.
|
||||
10. Retains m3taversal's caveats and review notes in source/evidence/proposal rows.
|
||||
11. Reasons over claims, evidence, sources, edges, and open conflicts as a graph,
|
||||
and can explain which rows support or weaken an answer without being given IDs.
|
||||
12. Rebuilds any compiled identity/workspace artifact deterministically from
|
||||
canonical DB rows, reports the source rows and freshness boundary, and does
|
||||
not treat edits to a generated artifact as canonical knowledge.
|
||||
13. Produces before/after table-level proof and service stability readback.
|
||||
14. Survives an intentional `leoclean-gateway.service` restart: active before, active after, canonical KB counts unchanged, and a no-post handler smoke still answers.
|
||||
15. For a reviewed graph bundle, produces exact payload-controlled row
|
||||
projections, exact table deltas, source-byte binding, and cleanup proof in a
|
||||
disposable runtime before any production permission or apply window.
|
||||
|
||||
## Current Benchmark Cases
|
||||
|
||||
- Historical marker-memory receipt:
|
||||
`docs/reports/leo-working-state-20260709/telegram-live-canary-current.json`.
|
||||
- Truth correction: `approved != applied`.
|
||||
- Applied canary: proposal `00957f6c-9883-4015-95a4-6b09367efb0e` and edge `c167933e-d513-4f43-9335-d5d8aeb259f2`.
|
||||
- Staged write canary: proposal `8dfedb3f-3aa4-4200-970f-4c0016f6869f`, status `pending_review`, with no new public canonical rows after the test timestamp.
|
||||
- Open-ended triage receipt:
|
||||
`docs/reports/leo-working-state-20260709/telegram-live-open-ended-suite-current.md`,
|
||||
where Leo inferred the likely failure mode from "agents not working / same
|
||||
state as last night" and explained the proposed, approved, and applied split
|
||||
without exact IDs.
|
||||
- Old rich proposal packets: `14fa5ecc...`, `ac036c9d...`, and `a64df080...` are not to be silently production-applied.
|
||||
- Guarded apply proof: both generic and real Helmer v3 receipts pass `37/37` in
|
||||
disposable PostgreSQL; production Helmer remains unapplied.
|
||||
- Clone-bound handler proof: the real VPS `GatewayRunner` can inspect the full
|
||||
current-data disposable clone, discover the Helmer proposal, distinguish
|
||||
`approved` from `applied`, and produce bound bridge-call evidence without a
|
||||
Telegram post or production change. The proof surface must expose no delivery
|
||||
adapters or send tool, restrict terminal execution to clone-bound read-only KB
|
||||
verbs, kill the handler process group on timeout, and compare production
|
||||
row-content fingerprints before and after. Treat open-ended latency and
|
||||
retries as a separate reliability dimension; a correct answer after a failed
|
||||
tool call is recovered behavior, not a clean pass, and one correct answer does
|
||||
not establish a stable pass rate.
|
||||
- Source-composition proof: the real VPS `GatewayRunner` passed `34/34` in a
|
||||
fresh no-send full-data clone. It searched existing knowledge, extracted two
|
||||
hash-bound conflicting claims from a new document and post, staged a strict
|
||||
proposal, preserved an immutable separated approval, applied exact canonical
|
||||
rows, reopened in a new child process, recalled the prior marker, discovered
|
||||
the new proposal/claims without supplied IDs, read exact evidence/source UUIDs
|
||||
and edges, and explained `approved != applied`. Production DB fingerprints,
|
||||
service PID/restarts, and live bridge hashes stayed unchanged; all disposable
|
||||
containers, volumes, profiles, credentials, and run directories were removed.
|
||||
This is clone proof, not Telegram delivery or production apply proof.
|
||||
|
||||
## Direct Questions
|
||||
|
||||
For vague or no-context questions, answer directly and then name the one action
|
||||
that would change the proof:
|
||||
|
||||
- "Did the DB change?": split applied rows, approved-but-unapplied proposals,
|
||||
pending rows, and canceled rows. Say `Approved is not the same as applied`.
|
||||
- "Is Helmer in Leo?": say `no, not canonical` until the exact claims, sources,
|
||||
evidence, edges, reasoning tool, and applied ledger read back from production.
|
||||
- "Did the decision matrix approve it?": verify matrix tables first; reviewer
|
||||
approval is not a matrix vote.
|
||||
- "Are document pointers the blocker?": answer `not just pointer mismatch` and
|
||||
separate files, source refs, canonical source rows, review, and apply.
|
||||
- "Can I demo KB mutation?": split staging-demo truth from canonical-apply truth.
|
||||
- "Did editing SOUL.md change identity?": no canonical identity change without
|
||||
row IDs plus render/sync postflight.
|
||||
|
||||
End no-context answers with exactly one `Next proof-changing follow-up:` line.
|
||||
|
||||
## Current Verdict - 2026-07-14
|
||||
|
||||
Use `not fully yet` for the whole m3taversal-standard question until every row below
|
||||
is green at its required tier:
|
||||
|
||||
- VPS runtime, database-first direct questions, and intentional restart
|
||||
survival are proven at no-send/runtime tier. The newest captured VPS canonical
|
||||
DB has `39` tables, `52,167` rows, and `29` proposals.
|
||||
- One natural ID-free VPS claim challenge now passes the complete bounded chain:
|
||||
discover claim, inspect body/evidence/edges, challenge shallow support,
|
||||
propose candidate claims, and preserve review before any write. This does not
|
||||
erase the older broad 12-prompt failure; broad repeated reliability remains a
|
||||
separate benchmark row.
|
||||
- A fresh VPS snapshot was restored into a disposable private GCP Cloud SQL
|
||||
database with exact `39/39`-table and `52,167/52,167`-row parity, zero catalog
|
||||
mismatches, and unchanged production service state.
|
||||
- The same GCP ID-free challenge passed `18/18` runtime checks and `6/6`
|
||||
reasoning outcomes through the real Hermes runner. It used four successful
|
||||
read-only, receipted calls and retrieved the expected claim plus both source
|
||||
rows. No Telegram send or database write occurred.
|
||||
- The GCP helper now gives deterministic receipts for every read, handles a
|
||||
canonical-only restored DB without the optional audit schema, and returns an
|
||||
honest zero-hit result instead of crashing.
|
||||
- The reviewed helper/skill are merged and deployed. GCP survived a controlled
|
||||
restart, then live `status` and `search` reached persistent Cloud SQL with
|
||||
unchanged counts and stable receipts. Supported commands fail closed instead
|
||||
of falling back to a different local database.
|
||||
- The disposable GCP database and temporary profiles/processes were removed.
|
||||
Persistent GCP `teleo_canonical` remains the older staging copy and has not
|
||||
been promoted or cut over.
|
||||
- Earlier Telegram-visible open-ended behavior and conversation memory are
|
||||
retained proof, but there is no current Telegram-visible run of this full
|
||||
challenge-to-candidate-claims flow.
|
||||
- Canonical apply primitives are proven in isolation. No proposal from this
|
||||
claim challenge was staged, reviewed, or production-applied, and broad
|
||||
arbitrary document/post reconstruction from original sources remains open.
|
||||
|
||||
Do not answer `yes` merely because all repo tests pass. The final user-facing
|
||||
proof is a visible Telegram conversation plus truthful canonical row readback.
|
||||
|
||||
## Hard m3taversal Question Bank
|
||||
|
||||
Use these without IDs, schema hints, or guardrail-heavy setup. The answer must
|
||||
infer the relevant rows and end with one proof-changing follow-up:
|
||||
|
||||
1. `I approved Helmer weeks ago. Why isn't it real yet?`
|
||||
2. `What can I show a partner in five minutes?`
|
||||
3. `This PDF should update Leo. What happens now?`
|
||||
4. `What changed in your identity since last week?`
|
||||
5. `Make this claim canonical: <claim text>.`
|
||||
6. `Are pending proposals stuck because sources are wrong?`
|
||||
7. `What does our strategy depend on, and which evidence could overturn it?`
|
||||
8. `What's the next KB change I should approve?`
|
||||
|
||||
Score the answer on directness, fresh canonical lookup, claim/evidence/source
|
||||
reasoning, state semantics, uncertainty, row-level proof, and the usefulness of
|
||||
the next action. Penalize asking m3taversal for IDs that Leo can discover itself.
|
||||
|
||||
Also fail the answer when it presents proposed architecture as current v1. The
|
||||
current `public.claims` table has `text` and `superseded_by`, not `body`, generic
|
||||
metadata, or forecast-resolution fields. Current `public.sources` has no
|
||||
author/channel/date columns. Current accepted edge types are `supports`,
|
||||
`challenges`, `requires`, `relates`, `contradicts`, `supersedes`,
|
||||
`derives_from`, `cites`, `causes`, `constrains`, and `accelerates`.
|
||||
|
||||
## Required Answer Discipline
|
||||
|
||||
When Leo or a worker answers about KB state, it must say:
|
||||
|
||||
- What source was used: memory, Telegram history, `kb_stage`, canonical `public.*`, or filesystem/runtime.
|
||||
- Which rows/tables changed.
|
||||
- Which proposal IDs are only staged/approved.
|
||||
- Which canonical rows exist.
|
||||
- What remains pending or deferred.
|
||||
|
||||
## Not Done
|
||||
|
||||
Leo is not working just because:
|
||||
|
||||
- the systemd service is active,
|
||||
- Bot API calls work,
|
||||
- a local handler canary passed,
|
||||
- a proposal was approved,
|
||||
- a packet was generated,
|
||||
- a clone rehearsal passed.
|
||||
- `NRestarts=0` was observed without an intentional restart-cycle proof.
|
||||
|
||||
Those are evidence. The target is Telegram-visible behavior plus DB-state truth plus a guarded path to canonical rows.
|
||||
|
||||
A synthetic one-claim proposal proves only the guarded mutation primitive. It
|
||||
does not prove KB composition. Composition requires a source that was absent at
|
||||
baseline, model-driven extraction into linked source/evidence/claim proposals,
|
||||
dedupe/conflict readback, guarded canonical apply, and an open-ended answer
|
||||
grounded in the new rows after an isolated handler restart.
|
||||
|
||||
## Proof Files
|
||||
|
||||
Start with `docs/reports/leo-working-state-20260709/working-leo-current-proof-20260712.md`
|
||||
and its JSON companion for the current whole-system verdict and completion rule.
|
||||
Use `docs/reports/leo-working-state-20260709/current-truth-index.md` for current artifact paths.
|
||||
Use `docs/reports/leo-working-state-20260709/leo-source-composition-clone-checkpoint-current.md`
|
||||
and its JSON companion for the current source-composition claim ceiling.
|
||||
For restart survival, use `scripts/collect_leo_restart_survival_proof.py --execute-restart`; expected artifacts are `docs/reports/leo-working-state-20260709/leo-restart-survival-proof-current.json` and `.md`.
|
||||
187
.crabbox.yaml
187
.crabbox.yaml
|
|
@ -1,187 +0,0 @@
|
|||
profile: teleo-infrastructure-check
|
||||
provider: hetzner
|
||||
target: linux
|
||||
architecture: arm64
|
||||
class: beast
|
||||
ttl: 90m
|
||||
idleTimeout: 20m
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox.yml
|
||||
job: hydrate
|
||||
runnerLabels:
|
||||
- crabbox
|
||||
runnerVersion: latest
|
||||
ephemeral: true
|
||||
sync:
|
||||
delete: true
|
||||
checksum: false
|
||||
gitSeed: true
|
||||
fingerprint: true
|
||||
timeout: 15m
|
||||
warnFiles: 50000
|
||||
warnBytes: 5368709120
|
||||
failFiles: 150000
|
||||
failBytes: 21474836480
|
||||
exclude:
|
||||
- .cache
|
||||
- .venv
|
||||
- .pytest_cache
|
||||
- .ruff_cache
|
||||
- __pycache__
|
||||
- "*.pyc"
|
||||
- "*.db"
|
||||
- "*.db-wal"
|
||||
- "*.db-shm"
|
||||
- "*.log"
|
||||
- logs
|
||||
- secrets
|
||||
- .env
|
||||
- htmlcov
|
||||
- dist
|
||||
- build
|
||||
- "*.egg-info"
|
||||
- .turbo
|
||||
- node_modules
|
||||
env:
|
||||
allow:
|
||||
- CI
|
||||
- PYTHONWARNINGS
|
||||
- PHASE1B_AGENT_ROUTING_ENABLED
|
||||
ssh:
|
||||
user: crabbox
|
||||
port: "2222"
|
||||
# Ordered fallback ports tried after ssh.port; use [] to disable fallback.
|
||||
fallbackPorts:
|
||||
- "22"
|
||||
|
||||
jobs:
|
||||
ci-contract:
|
||||
provider: hetzner
|
||||
target: linux
|
||||
architecture: arm64
|
||||
class: beast
|
||||
hydrate:
|
||||
actions: true
|
||||
githubRunner: false
|
||||
waitTimeout: 20m
|
||||
keepAliveMinutes: 90
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox.yml
|
||||
job: hydrate
|
||||
shell: true
|
||||
command: >
|
||||
python3 -m pip install -e '.[dev]' &&
|
||||
mkdir -p .crabbox-results &&
|
||||
python3 scripts/check_crabbox_ci_contract.py
|
||||
--output .crabbox-results/crabbox-ci-contract.json &&
|
||||
python3 scripts/check_llm_refinement_contract.py
|
||||
--output .crabbox-results/llm-refinement-contract.json &&
|
||||
python3 scripts/replay_decision_engine_eval.py
|
||||
--output .crabbox-results/decision-engine-eval.json
|
||||
downloads:
|
||||
- .crabbox-results/crabbox-ci-contract.json
|
||||
- .crabbox-results/llm-refinement-contract.json
|
||||
- .crabbox-results/decision-engine-eval.json
|
||||
stop: always
|
||||
|
||||
unit:
|
||||
provider: hetzner
|
||||
target: linux
|
||||
architecture: arm64
|
||||
class: beast
|
||||
hydrate:
|
||||
actions: true
|
||||
githubRunner: false
|
||||
waitTimeout: 20m
|
||||
keepAliveMinutes: 90
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox.yml
|
||||
job: hydrate
|
||||
shell: true
|
||||
command: >
|
||||
python3 -m pip install -e '.[dev]' &&
|
||||
mkdir -p .crabbox-results &&
|
||||
python3 -m pytest --junitxml=.crabbox-results/pytest.xml
|
||||
junit:
|
||||
- .crabbox-results/pytest.xml
|
||||
downloads:
|
||||
- .crabbox-results/pytest.xml
|
||||
stop: always
|
||||
|
||||
lint-phase1b:
|
||||
provider: hetzner
|
||||
target: linux
|
||||
architecture: arm64
|
||||
class: beast
|
||||
hydrate:
|
||||
actions: true
|
||||
githubRunner: false
|
||||
waitTimeout: 20m
|
||||
keepAliveMinutes: 90
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox.yml
|
||||
job: hydrate
|
||||
shell: true
|
||||
command: >
|
||||
python3 -m pip install -e '.[dev]' &&
|
||||
python3 -m ruff check
|
||||
lib/agent_routing.py
|
||||
lib/config.py
|
||||
lib/db.py
|
||||
lib/evaluate.py
|
||||
lib/llm.py
|
||||
lib/post_extract.py
|
||||
telegram/approvals.py
|
||||
scripts/prove_phase1b_local.py
|
||||
tests/test_agent_routing.py
|
||||
tests/test_evaluate_agent_routing.py
|
||||
tests/test_phase1b_end_to_end.py
|
||||
tests/test_eval_parse.py
|
||||
tests/test_contributor.py
|
||||
tests/test_search.py
|
||||
stop: always
|
||||
|
||||
phase1b-local-proof:
|
||||
provider: hetzner
|
||||
target: linux
|
||||
architecture: arm64
|
||||
class: beast
|
||||
hydrate:
|
||||
actions: true
|
||||
githubRunner: false
|
||||
waitTimeout: 20m
|
||||
keepAliveMinutes: 90
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox.yml
|
||||
job: hydrate
|
||||
shell: true
|
||||
command: >
|
||||
python3 -m pip install -e '.[dev]' &&
|
||||
scripts/crabbox_phase1b_proof.sh
|
||||
junit:
|
||||
- .crabbox-results/phase1b-pytest.xml
|
||||
downloads:
|
||||
- .crabbox-results/crabbox-ci-contract.json
|
||||
- proof/phase1b-local-e2e-proof.json
|
||||
- .crabbox-results/phase1b-pytest.xml
|
||||
- .crabbox-results/phase1b-proof-summary.json
|
||||
stop: always
|
||||
|
||||
sync-smoke:
|
||||
provider: hetzner
|
||||
target: linux
|
||||
architecture: arm64
|
||||
class: beast
|
||||
hydrate:
|
||||
actions: false
|
||||
shell: true
|
||||
command: >
|
||||
python3 -m compileall
|
||||
lib
|
||||
tests
|
||||
scripts/prove_phase1b_local.py
|
||||
stop: always
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
.env
|
||||
*.env
|
||||
secrets/
|
||||
telegram-archives/
|
||||
transcripts/
|
||||
logs/
|
||||
pipeline.db
|
||||
pipeline.db-*
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
agentcash/
|
||||
.agentcash/
|
||||
.hermes/
|
||||
node_modules/
|
||||
314
.github/workflows/ci.yml
vendored
314
.github/workflows/ci.yml
vendored
|
|
@ -1,314 +0,0 @@
|
|||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
CI: "1"
|
||||
LEOCLEAN_RUNTIME_POSTGRES_IMAGE: "postgres@sha256:eb4759788a2182f08257135e61a34f2cfc3c2914079f3465d64ee62350f4d081"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Focused lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
- name: Ruff focused surface
|
||||
run: |
|
||||
python -m ruff check \
|
||||
lib/agent_routing.py \
|
||||
lib/config.py \
|
||||
lib/db.py \
|
||||
lib/evaluate.py \
|
||||
lib/llm.py \
|
||||
lib/post_extract.py \
|
||||
docker/leoclean-nosend/entrypoint.py \
|
||||
ops/apply_gcp_iam_split.py \
|
||||
ops/attest_gcp_reasoning_compute.py \
|
||||
ops/capture_vps_canonical_postgres_snapshot.py \
|
||||
ops/check_gcp_leoclean_nosend_preflight.py \
|
||||
ops/check_gcp_infra_readiness.py \
|
||||
ops/run_gcp_infra_execute_canary.py \
|
||||
ops/apply_gcp_runtime_baseline.py \
|
||||
ops/check_gcp_service_communications.py \
|
||||
ops/derive_leoclean_runtime_query_contract.py \
|
||||
ops/gcp_leoclean_nosend_package.py \
|
||||
ops/plan_gcp_iam_split.py \
|
||||
ops/private_receipt_io.py \
|
||||
ops/run_local_canonical_postgres_rebuild.py \
|
||||
ops/redact_sqlite_postgres_restore_canary.py \
|
||||
ops/restore_gcp_generated_postgres_snapshot.py \
|
||||
ops/sqlite_to_postgres_dump.py \
|
||||
ops/verify_gcp_cloudsql_restore_readback.py \
|
||||
ops/verify_gcp_leoclean_runtime_permissions.py \
|
||||
ops/verify_gcp_leoclean_service_environment.py \
|
||||
ops/verify_gcp_canonical_lifecycle.py \
|
||||
ops/verify_postgres_parity_manifest.py \
|
||||
ops/verify_vps_canonical_snapshot_delta.py \
|
||||
telegram/approvals.py \
|
||||
hermes-agent/patches/enforce_leoclean_learning_policy.py \
|
||||
hermes-agent/leoclean-bin/kb_tool.py \
|
||||
hermes-agent/leoclean-bin/cloudsql_memory_tool.py \
|
||||
hermes-agent/runtime/leoclean-nosend/bootstrap.py \
|
||||
hermes-agent/runtime/leoclean-nosend/profile-sources/cloudsql_memory_runtime.py \
|
||||
hermes-agent/runtime/leoclean-nosend/profile-sources/leo_db_context.py \
|
||||
hermes-agent/runtime/leoclean-nosend/profile-sources/operational_contracts.py \
|
||||
scripts/check_crabbox_ci_contract.py \
|
||||
scripts/check_llm_refinement_contract.py \
|
||||
scripts/build_working_leo_m3taversal_outcome_sandbox.py \
|
||||
scripts/leo_behavior_manifest.py \
|
||||
scripts/leo_identity_manifest.py \
|
||||
scripts/leo_tool_trace.py \
|
||||
scripts/compile_leoclean_nosend_runtime.py \
|
||||
scripts/replay_decision_engine_eval.py \
|
||||
scripts/prove_phase1b_local.py \
|
||||
scripts/run_gcp_generated_db_direct_claim_suite.py \
|
||||
scripts/run_gcp_generated_db_working_leo_suite.py \
|
||||
scripts/run_gcp_generated_db_blind_claim_canary.py \
|
||||
scripts/run_leo_direct_claim_handler_suite.py \
|
||||
scripts/run_leo_m3taversal_oos_handler_suite.py \
|
||||
scripts/verify_leo_db_first_oos_canary.py \
|
||||
scripts/run_leo_clone_bound_handler_checkpoint.py \
|
||||
scripts/run_gcp_leoclean_nosend_oci_smoke.py \
|
||||
scripts/working_leo_m3taversal_oos_benchmark.py \
|
||||
scripts/working_leo_open_ended_benchmark.py \
|
||||
scripts/verify_leoclean_nosend_runtime.py \
|
||||
tests/test_agent_routing.py \
|
||||
tests/test_attest_gcp_reasoning_compute.py \
|
||||
tests/test_assemble_telegram_visible_direct_claim_capture_receipt.py \
|
||||
tests/test_build_working_leo_m3taversal_outcome_sandbox.py \
|
||||
tests/test_decision_engine_replay.py \
|
||||
tests/test_evaluate_agent_routing.py \
|
||||
tests/test_gcp_artifact_workflow.py \
|
||||
tests/test_gcp_leoclean_nosend_release_workflow.py \
|
||||
tests/test_capture_vps_canonical_postgres_snapshot.py \
|
||||
tests/test_gcp_infra_execute_canary.py \
|
||||
tests/test_gcp_infra_readiness_checker.py \
|
||||
tests/test_gcp_leoclean_nosend_preflight.py \
|
||||
tests/test_gcp_leoclean_runtime_permissions.py \
|
||||
tests/test_gcp_leoclean_nosend_package.py \
|
||||
tests/test_gcp_leoclean_nosend_oci_smoke.py \
|
||||
tests/test_gcp_leoclean_service_environment.py \
|
||||
tests/test_gcp_runtime_baseline_apply.py \
|
||||
tests/test_gcp_service_communications.py \
|
||||
tests/test_gcp_cloudsql_restore_drill.py \
|
||||
tests/test_gcp_cloudsql_restore_readback.py \
|
||||
tests/test_gcp_iam_split_apply.py \
|
||||
tests/test_gcp_iam_split_plan.py \
|
||||
tests/test_gcp_readiness_workflow.py \
|
||||
tests/test_gcp_generated_db_direct_claim_suite.py \
|
||||
tests/test_gcp_generated_db_working_leo_suite.py \
|
||||
tests/test_gcp_generated_db_blind_claim_canary.py \
|
||||
tests/test_hermes_leoclean_kb_bridge_source.py \
|
||||
tests/test_hermes_leoclean_db_context_plugin.py \
|
||||
tests/test_hermes_leoclean_skill_surfaces.py \
|
||||
tests/test_leoclean_nosend_runtime.py \
|
||||
tests/test_leo_behavior_manifest.py \
|
||||
tests/test_leo_tool_trace.py \
|
||||
tests/test_verify_leo_db_first_oos_canary.py \
|
||||
tests/test_compile_kb_source_packet.py \
|
||||
tests/test_verify_postgres_parity_manifest.py \
|
||||
tests/test_verify_gcp_canonical_lifecycle.py \
|
||||
tests/test_verify_vps_canonical_snapshot_delta.py \
|
||||
tests/test_working_leo_m3taversal_oos_benchmark.py \
|
||||
tests/test_working_leo_open_ended_benchmark.py \
|
||||
tests/test_phase1b_end_to_end.py \
|
||||
tests/test_private_receipt_io.py \
|
||||
tests/test_restore_gcp_generated_postgres_snapshot.py \
|
||||
tests/test_run_local_canonical_postgres_rebuild.py \
|
||||
tests/test_run_local_genesis_ledger_rebuild.py \
|
||||
tests/test_sqlite_to_postgres_dump.py \
|
||||
tests/test_sqlite_postgres_restore_canary_capsule.py \
|
||||
tests/test_teleo_agent_systemd.py \
|
||||
tests/test_eval_parse.py \
|
||||
tests/test_contributor.py \
|
||||
tests/test_search.py
|
||||
- name: Shell syntax
|
||||
run: |
|
||||
bash -n \
|
||||
deploy/sync-gcp-leoclean-runtime.sh \
|
||||
hermes-agent/leoclean-bin/teleo-kb \
|
||||
hermes-agent/leoclean-bin/teleo-kb-gcp \
|
||||
ops/backup_vps_sqlite_kb.sh \
|
||||
ops/provision_gcp_leoclean_runtime_role.sh \
|
||||
ops/run_gcp_cloudsql_restore_drill.sh \
|
||||
ops/run_sqlite_postgres_restore_canary.sh \
|
||||
scripts/run_leoclean_nosend_runtime_canary.sh
|
||||
|
||||
test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
- name: Pull digest-pinned PostgreSQL permission canary image
|
||||
run: docker pull "${LEOCLEAN_RUNTIME_POSTGRES_IMAGE}"
|
||||
- name: Pytest
|
||||
env:
|
||||
TELEO_RUN_LEOCLEAN_RUNTIME_POSTGRES_CANARY: "1"
|
||||
run: |
|
||||
mkdir -p .crabbox-results
|
||||
python -m pytest --junitxml=.crabbox-results/pytest.xml
|
||||
- name: Upload test artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: teleo-infrastructure-pytest
|
||||
path: .crabbox-results/pytest.xml
|
||||
if-no-files-found: warn
|
||||
- name: Remove interrupted PostgreSQL permission canaries
|
||||
if: always()
|
||||
run: |
|
||||
mapfile -t containers < <(docker ps -aq --filter "label=livingip.r2.permission-canary")
|
||||
if ((${#containers[@]})); then
|
||||
docker rm --force "${containers[@]}"
|
||||
fi
|
||||
test -z "$(docker ps -aq --filter 'label=livingip.r2.permission-canary')"
|
||||
|
||||
repo-contracts:
|
||||
name: Repo contracts
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
- name: Validate repo-owned contract
|
||||
run: |
|
||||
python scripts/check_crabbox_ci_contract.py \
|
||||
--output .crabbox-results/crabbox-ci-contract.json
|
||||
python scripts/check_llm_refinement_contract.py \
|
||||
--output .crabbox-results/llm-refinement-contract.json
|
||||
python scripts/replay_decision_engine_eval.py \
|
||||
--output .crabbox-results/decision-engine-eval.json
|
||||
- name: Upload contract artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: teleo-infrastructure-repo-contracts
|
||||
path: |
|
||||
.crabbox-results/crabbox-ci-contract.json
|
||||
.crabbox-results/llm-refinement-contract.json
|
||||
.crabbox-results/decision-engine-eval.json
|
||||
if-no-files-found: error
|
||||
|
||||
leoclean-nosend-runtime:
|
||||
name: Leoclean no-send runtime
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11.9"
|
||||
- name: Install exact uv toolchain
|
||||
run: python -m pip install 'uv==0.9.30' 'PyYAML==6.0.3'
|
||||
- name: Pull digest-pinned OCI bases
|
||||
run: |
|
||||
docker pull --platform=linux/amd64 "ghcr.io/astral-sh/uv:0.9.30@sha256:538e0b39736e7feae937a65983e49d2ab75e1559d35041f9878b7b7e51de91e4"
|
||||
docker pull --platform=linux/amd64 "docker.io/library/python:3.11.9-slim-bookworm@sha256:8fb099199b9f2d70342674bd9dbccd3ed03a258f26bbd1d556822c6dfc60c317"
|
||||
docker pull --platform=linux/amd64 "docker.io/library/postgres:16.14-bookworm@sha256:92620daddcd947f8d5ab5ba66e848702fe443d87fed30c4cea8e389fd78dfc55"
|
||||
- name: Compile and exercise release artifact
|
||||
env:
|
||||
LEOCLEAN_NOSEND_ARTIFACT_OUTPUT: ${{ runner.temp }}/leoclean-nosend-artifact
|
||||
run: scripts/run_leoclean_nosend_runtime_canary.sh
|
||||
- name: Build and exercise one disposable OCI image
|
||||
run: |
|
||||
python scripts/run_gcp_leoclean_nosend_oci_smoke.py \
|
||||
--artifact "${RUNNER_TEMP}/leoclean-nosend-artifact" \
|
||||
--runtime-receipt .crabbox-results/leoclean-nosend-runtime.json \
|
||||
--output .crabbox-results/gcp-leoclean-nosend-oci-smoke.json
|
||||
- name: Upload no-send runtime receipt
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: teleo-infrastructure-leoclean-nosend-runtime
|
||||
path: |
|
||||
.crabbox-results/leoclean-nosend-runtime.json
|
||||
.crabbox-results/gcp-leoclean-nosend-oci-smoke.json
|
||||
if-no-files-found: error
|
||||
- name: Remove interrupted no-send OCI smokes
|
||||
if: always()
|
||||
run: |
|
||||
container_inventory="$(docker ps -aq --filter 'label=livingip.leoclean-nosend-smoke')"
|
||||
if [[ -n "${container_inventory}" ]]; then
|
||||
mapfile -t containers <<<"${container_inventory}"
|
||||
docker rm --force "${containers[@]}"
|
||||
fi
|
||||
image_inventory="$(docker images -q --filter 'label=livingip.leoclean-nosend-smoke')"
|
||||
if [[ -n "${image_inventory}" ]]; then
|
||||
mapfile -t images <<<"${image_inventory}"
|
||||
docker image rm --force "${images[@]}"
|
||||
fi
|
||||
remaining_containers="$(docker ps -aq --filter 'label=livingip.leoclean-nosend-smoke')"
|
||||
remaining_images="$(docker images -q --filter 'label=livingip.leoclean-nosend-smoke')"
|
||||
test -z "${remaining_containers}"
|
||||
test -z "${remaining_images}"
|
||||
|
||||
phase1b-local-proof:
|
||||
name: Phase 1B local proof
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
- repo-contracts
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PHASE1B_AGENT_ROUTING_ENABLED: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
- name: Run proof wrapper
|
||||
run: |
|
||||
scripts/crabbox_phase1b_proof.sh
|
||||
- name: Upload proof artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: teleo-infrastructure-phase1b-proof
|
||||
path: |
|
||||
.crabbox-results/crabbox-ci-contract.json
|
||||
proof/phase1b-local-e2e-proof.json
|
||||
.crabbox-results/phase1b-pytest.xml
|
||||
.crabbox-results/phase1b-proof-summary.json
|
||||
if-no-files-found: warn
|
||||
101
.github/workflows/crabbox.yml
vendored
101
.github/workflows/crabbox.yml
vendored
|
|
@ -1,101 +0,0 @@
|
|||
name: crabbox
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref to hydrate"
|
||||
required: false
|
||||
type: string
|
||||
crabbox_id:
|
||||
description: "Crabbox lease ID"
|
||||
required: true
|
||||
type: string
|
||||
crabbox_runner_label:
|
||||
description: "Dynamic Crabbox runner label"
|
||||
required: true
|
||||
type: string
|
||||
crabbox_job:
|
||||
description: "Hydration job identifier expected by Crabbox"
|
||||
required: false
|
||||
default: "hydrate"
|
||||
type: string
|
||||
crabbox_keep_alive_minutes:
|
||||
description: "Minutes to keep the hydrated job alive"
|
||||
required: false
|
||||
default: "90"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
hydrate:
|
||||
runs-on: [self-hosted, "${{ inputs.crabbox_runner_label }}"]
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Hydrate
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
if [ -f package-lock.json ]; then npm ci; fi
|
||||
if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; fi
|
||||
if [ -f go.mod ]; then go mod download; fi
|
||||
- name: Mark Crabbox ready
|
||||
shell: bash
|
||||
run: |
|
||||
job="${{ inputs.crabbox_job }}"
|
||||
if [ -z "$job" ]; then job=hydrate; fi
|
||||
mkdir -p "$HOME/.crabbox/actions"
|
||||
state="$HOME/.crabbox/actions/${{ inputs.crabbox_id }}.env"
|
||||
env_file="$HOME/.crabbox/actions/${{ inputs.crabbox_id }}.env.sh"
|
||||
services_file="$HOME/.crabbox/actions/${{ inputs.crabbox_id }}.services"
|
||||
write_export() {
|
||||
key="$1"
|
||||
value="${!key-}"
|
||||
if [ -n "$value" ]; then
|
||||
printf 'export %s=%q\n' "$key" "$value"
|
||||
fi
|
||||
}
|
||||
{
|
||||
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR GITHUB_JOB RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE; do
|
||||
write_export "$key"
|
||||
done
|
||||
} > "${env_file}.tmp"
|
||||
mv "${env_file}.tmp" "$env_file"
|
||||
{
|
||||
echo "# Docker containers visible from the hydrated runner"
|
||||
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}' 2>/dev/null || true
|
||||
} > "${services_file}.tmp"
|
||||
mv "${services_file}.tmp" "$services_file"
|
||||
tmp="${state}.tmp"
|
||||
{
|
||||
echo "WORKSPACE=${GITHUB_WORKSPACE}"
|
||||
echo "RUN_ID=${GITHUB_RUN_ID}"
|
||||
echo "JOB=${job}"
|
||||
echo "ENV_FILE=${env_file}"
|
||||
echo "SERVICES_FILE=${services_file}"
|
||||
echo "READY_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
} > "$tmp"
|
||||
mv "$tmp" "$state"
|
||||
- name: Keep Crabbox job alive
|
||||
shell: bash
|
||||
run: |
|
||||
minutes="${{ inputs.crabbox_keep_alive_minutes }}"
|
||||
case "$minutes" in
|
||||
''|*[!0-9]*) minutes=90 ;;
|
||||
esac
|
||||
stop="$HOME/.crabbox/actions/${{ inputs.crabbox_id }}.stop"
|
||||
deadline=$(( $(date +%s) + minutes * 60 ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if [ -f "$stop" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
90
.github/workflows/gcp-artifact.yml
vendored
90
.github/workflows/gcp-artifact.yml
vendored
|
|
@ -1,90 +0,0 @@
|
|||
name: gcp-artifact
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: gcp-artifact-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
REGION: europe-west6
|
||||
ARTIFACT_REPOSITORY: teleo
|
||||
IMAGE_NAME: teleo-pipeline-gcp-staging
|
||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
ARTIFACT_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||
|
||||
jobs:
|
||||
build-smoke-push:
|
||||
name: Build, smoke-run, and push Docker image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Require manual main dispatch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "${GITHUB_EVENT_NAME}" = "workflow_dispatch"
|
||||
test "${GITHUB_REF}" = "refs/heads/main"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.ARTIFACT_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Configure Artifact Registry Docker auth
|
||||
run: |
|
||||
gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
||||
|
||||
- name: Build, smoke-run, and push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${GITHUB_SHA::7}"
|
||||
image_uri="${REGION}-docker.pkg.dev/${PROJECT_ID}/${ARTIFACT_REPOSITORY}/${IMAGE_NAME}:${tag}"
|
||||
image_digest="$(gcloud artifacts docker images describe "${image_uri}" --format='value(image_summary.digest)' 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "${image_digest}" ]]; then
|
||||
echo "Image tag already exists; reusing immutable Artifact Registry image."
|
||||
docker pull "${image_uri}@${image_digest}"
|
||||
docker run --rm "${image_uri}@${image_digest}"
|
||||
else
|
||||
docker build \
|
||||
-f Dockerfile.gcp-staging \
|
||||
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||
--label "livingip.revision=${GITHUB_SHA}" \
|
||||
--label "livingip.surface=teleo-infrastructure" \
|
||||
--label "livingip.tier=gcp-staging" \
|
||||
-t "${image_uri}" \
|
||||
.
|
||||
|
||||
docker run --rm "${image_uri}"
|
||||
docker push "${image_uri}" | tee docker-push.log
|
||||
image_digest="$(awk '/digest: sha256:/ {print $3}' docker-push.log | tail -1)"
|
||||
fi
|
||||
test -n "${image_digest}"
|
||||
|
||||
{
|
||||
echo "image_uri=${image_uri}"
|
||||
echo "image_digest=${image_digest}"
|
||||
echo "image_ref=${image_uri}@${image_digest}"
|
||||
echo "revision=${GITHUB_SHA}"
|
||||
} > gcp-artifact-image.txt
|
||||
|
||||
- name: Upload image receipt
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gcp-artifact-image
|
||||
path: gcp-artifact-image.txt
|
||||
if-no-files-found: error
|
||||
254
.github/workflows/gcp-iap-operator.yml
vendored
254
.github/workflows/gcp-iap-operator.yml
vendored
|
|
@ -1,254 +0,0 @@
|
|||
name: gcp-iap-operator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
operation:
|
||||
description: Fixed reviewed operation
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- status
|
||||
- direct-claim-replay
|
||||
- cleanup-clone
|
||||
request_id:
|
||||
description: iap- followed by 12-32 lowercase letters or digits
|
||||
required: true
|
||||
type: string
|
||||
target_db:
|
||||
description: teleo_clone_status for status, otherwise the exact generated clone database
|
||||
required: true
|
||||
default: teleo_clone_status
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: gcp-iap-operator-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
STATUS_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
CLONE_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/teleo-iap-operator
|
||||
STATUS_SERVICE_ACCOUNT: sa-teleo-iap-status@teleo-501523.iam.gserviceaccount.com
|
||||
CLONE_SERVICE_ACCOUNT: sa-teleo-iap-clone-operator@teleo-501523.iam.gserviceaccount.com
|
||||
EXPECTED_WORKFLOW_REF: living-ip/teleo-infrastructure/.github/workflows/gcp-iap-operator.yml@refs/heads/main
|
||||
DISPATCHER_VERSION: teleo-gcp-iap-operator-v1
|
||||
|
||||
jobs:
|
||||
operate:
|
||||
name: Fixed IAP operator command
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out the dispatched main revision
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize runner-local paths
|
||||
id: paths
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ -n "${RUNNER_TEMP:-}" ]] || exit 2
|
||||
printf 'BUNDLE_DIR=%s\n' "${RUNNER_TEMP}/gcp-iap-operator-bundle" >> "${GITHUB_ENV}"
|
||||
printf 'RESULT_DIR=%s\n' "${RUNNER_TEMP}/gcp-iap-operator-result" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Validate fixed inputs and select identity
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
REQUEST_ID: ${{ inputs.request_id }}
|
||||
TARGET_DB: ${{ inputs.target_db }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${GITHUB_REF}" == "refs/heads/main" ]] || exit 2
|
||||
[[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]] || exit 2
|
||||
request_id_re='^iap-[a-z0-9]{12,32}$'
|
||||
target_db_re='^teleo_clone_[a-z0-9][a-z0-9_]{0,50}$'
|
||||
[[ "${REQUEST_ID}" =~ ${request_id_re} ]] || exit 2
|
||||
[[ "${TARGET_DB}" =~ ${target_db_re} ]] || exit 2
|
||||
case "${OPERATION}" in
|
||||
status)
|
||||
[[ "${TARGET_DB}" == "teleo_clone_status" ]] || exit 2
|
||||
service_account="${STATUS_SERVICE_ACCOUNT}"
|
||||
workload_identity_provider="${STATUS_WORKLOAD_IDENTITY_PROVIDER}"
|
||||
;;
|
||||
direct-claim-replay|cleanup-clone)
|
||||
[[ "${TARGET_DB}" != "teleo_clone_status" ]] || exit 2
|
||||
service_account="${CLONE_SERVICE_ACCOUNT}"
|
||||
workload_identity_provider="${CLONE_WORKLOAD_IDENTITY_PROVIDER}"
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
printf 'service_account=%s\n' "${service_account}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'workload_identity_provider=%s\n' "${workload_identity_provider}" >> "${GITHUB_OUTPUT}"
|
||||
install -d -m 0700 "${RESULT_DIR}"
|
||||
: > "${RESULT_DIR}/result.json"
|
||||
chmod 0600 "${RESULT_DIR}/result.json"
|
||||
|
||||
- name: Build strict reviewed operation bundle
|
||||
if: ${{ inputs.operation != 'status' }}
|
||||
shell: bash
|
||||
env:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
REQUEST_ID: ${{ inputs.request_id }}
|
||||
TARGET_DB: ${{ inputs.target_db }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path.cwd().resolve()
|
||||
operation = os.environ["OPERATION"]
|
||||
request_id = os.environ["REQUEST_ID"]
|
||||
target_db = os.environ["TARGET_DB"]
|
||||
common = ["scripts/gcp_iap_operator.sh"]
|
||||
direct_claim = [
|
||||
"scripts/run_gcp_generated_db_direct_claim_suite.py",
|
||||
"scripts/run_leo_clone_bound_handler_checkpoint.py",
|
||||
"scripts/working_leo_open_ended_benchmark.py",
|
||||
"hermes-agent/leoclean-bin/cloudsql_memory_tool.py",
|
||||
"ops/postgres_parity_manifest.sql",
|
||||
f"docs/reports/leo-working-state-20260709/{target_db}-canonical-parity-receipt.json",
|
||||
]
|
||||
allowed = common + (direct_claim if operation == "direct-claim-replay" else [])
|
||||
if operation not in {"direct-claim-replay", "cleanup-clone"}:
|
||||
raise SystemExit("bundle requested for a non-clone operation")
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||
if head != os.environ["GITHUB_SHA"]:
|
||||
raise SystemExit("checkout HEAD does not match GITHUB_SHA")
|
||||
files = {}
|
||||
payloads = {}
|
||||
for relative in allowed:
|
||||
subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", "--", relative],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
path = root / relative
|
||||
if not path.is_file() or path.is_symlink() or root not in path.resolve().parents:
|
||||
raise SystemExit(f"unsafe or missing tracked bundle file: {relative}")
|
||||
data = path.read_bytes()
|
||||
payloads[relative] = data
|
||||
files[relative] = {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data)}
|
||||
manifest = {
|
||||
"schema": "livingip.gcpIapOperatorBundle.v1",
|
||||
"dispatcher_version": os.environ["DISPATCHER_VERSION"],
|
||||
"operation": operation,
|
||||
"request_id": request_id,
|
||||
"target_db": target_db,
|
||||
"git_commit": head,
|
||||
"ref": os.environ["GITHUB_REF"],
|
||||
"workflow_ref": os.environ["GITHUB_WORKFLOW_REF"],
|
||||
"files": files,
|
||||
}
|
||||
manifest_bytes = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()
|
||||
bundle_dir = Path(os.environ["BUNDLE_DIR"])
|
||||
bundle_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(bundle_dir, 0o700)
|
||||
bundle_path = bundle_dir / f"{request_id}.tar.gz"
|
||||
with bundle_path.open("xb") as raw:
|
||||
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for relative, data in sorted({**payloads, "bundle-manifest.json": manifest_bytes}.items()):
|
||||
info = tarfile.TarInfo(relative)
|
||||
info.size = len(data)
|
||||
info.mode = 0o600
|
||||
info.mtime = 0
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
os.chmod(bundle_path, 0o600)
|
||||
PY
|
||||
|
||||
- name: Authenticate with short-lived workload identity federation
|
||||
id: auth
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
workload_identity_provider: ${{ steps.validate.outputs.workload_identity_provider }}
|
||||
service_account: ${{ steps.validate.outputs.service_account }}
|
||||
create_credentials_file: true
|
||||
cleanup_credentials: true
|
||||
export_environment_variables: true
|
||||
|
||||
- name: Install Google Cloud CLI
|
||||
id: setup_gcloud
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
with:
|
||||
project_id: ${{ env.PROJECT_ID }}
|
||||
|
||||
- name: Run fixed IAP operation
|
||||
id: operate
|
||||
shell: bash
|
||||
env:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
REQUEST_ID: ${{ inputs.request_id }}
|
||||
TARGET_DB: ${{ inputs.target_db }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/gcp_iap_operator.sh "${OPERATION}" "${REQUEST_ID}" "${TARGET_DB}"
|
||||
|
||||
- name: Ensure sanitized result receipt
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
REQUEST_ID: ${{ inputs.request_id }}
|
||||
TARGET_DB: ${{ inputs.target_db }}
|
||||
VALIDATE_OUTCOME: ${{ steps.validate.outcome }}
|
||||
AUTH_OUTCOME: ${{ steps.auth.outcome }}
|
||||
SETUP_GCLOUD_OUTCOME: ${{ steps.setup_gcloud.outcome }}
|
||||
OPERATE_OUTCOME: ${{ steps.operate.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 "${RESULT_DIR}"
|
||||
result_path="${RESULT_DIR}/result.json"
|
||||
if [[ -s "${result_path}" ]] && python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${result_path}"; then
|
||||
exit 0
|
||||
fi
|
||||
RESULT_PATH="${result_path}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
result = {
|
||||
"schema": "livingip.gcpIapOperatorResult.v1",
|
||||
"operation": os.environ["OPERATION"],
|
||||
"request_id": os.environ["REQUEST_ID"],
|
||||
"target_db": os.environ["TARGET_DB"],
|
||||
"status": "fail",
|
||||
"failure_class": "workflow_stopped_before_valid_operator_result",
|
||||
"step_outcomes": {
|
||||
"validate": os.environ["VALIDATE_OUTCOME"],
|
||||
"auth": os.environ["AUTH_OUTCOME"],
|
||||
"setup_gcloud": os.environ["SETUP_GCLOUD_OUTCOME"],
|
||||
"operate": os.environ["OPERATE_OUTCOME"],
|
||||
},
|
||||
"credential_values_logged": False,
|
||||
"raw_stdout_retained": False,
|
||||
"raw_stderr_retained": False,
|
||||
}
|
||||
path = Path(os.environ["RESULT_PATH"])
|
||||
path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
PY
|
||||
|
||||
- name: Upload sanitized result receipt
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gcp-iap-operator-${{ github.run_id }}
|
||||
path: ${{ env.RESULT_DIR }}/result.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
333
.github/workflows/gcp-leoclean-nosend-release.yml
vendored
333
.github/workflows/gcp-leoclean-nosend-release.yml
vendored
|
|
@ -1,333 +0,0 @@
|
|||
name: gcp-leoclean-nosend-release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: gcp-leoclean-nosend-release-main
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
REGION: europe-west6
|
||||
ARTIFACT_REPOSITORY: teleo
|
||||
IMAGE_REPOSITORY: europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging
|
||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
ARTIFACT_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build, smoke, and publish exact no-send release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Require manual main dispatch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "${GITHUB_EVENT_NAME}" = "workflow_dispatch"
|
||||
test "${GITHUB_REF}" = "refs/heads/main"
|
||||
test "${GITHUB_REF_TYPE}" = "branch"
|
||||
test "${GITHUB_REPOSITORY}" = "living-ip/teleo-infrastructure"
|
||||
test "${GITHUB_WORKFLOW_REF}" = \
|
||||
"living-ip/teleo-infrastructure/.github/workflows/gcp-leoclean-nosend-release.yml@refs/heads/main"
|
||||
|
||||
- name: Check out exact dispatched revision
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Verify exact clean main revision
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse --verify HEAD)" = "${GITHUB_SHA}"
|
||||
test "$(git rev-parse --verify refs/remotes/origin/main^{commit})" = "${GITHUB_SHA}"
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
|
||||
- name: Initialize private release paths
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_root="${RUNNER_TEMP}/leoclean-nosend-release"
|
||||
install -d -m 0700 "${release_root}"
|
||||
{
|
||||
echo "RELEASE_ROOT=${release_root}"
|
||||
echo "RUNTIME_ARTIFACT=${release_root}/runtime-artifact"
|
||||
echo "RUNTIME_RECEIPT=${release_root}/evidence/runtime-receipt.json"
|
||||
echo "OCI_SMOKE_RECEIPT=${release_root}/evidence/oci-smoke.json"
|
||||
echo "IMAGE_CONTEXT=${release_root}/image-context"
|
||||
echo "BUILD_PUSH_RECEIPT=${release_root}/receipts/build-push-receipt.json"
|
||||
echo "PUBLISH_OUTCOME=${release_root}/evidence/publish-outcome.json"
|
||||
echo "RELEASE_BUNDLE=${release_root}/final/release-v3"
|
||||
echo "DOCKER_CONFIG=${release_root}/docker-config"
|
||||
} >>"${GITHUB_ENV}"
|
||||
|
||||
- name: Set up exact Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.11.9"
|
||||
|
||||
- name: Install exact no-send build tools
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check 'uv==0.9.30' 'PyYAML==6.0.3'
|
||||
test "$(uv --version | awk '{print $2}')" = "0.9.30"
|
||||
test "$(command -v docker)" = "/usr/bin/docker"
|
||||
|
||||
- name: Verify fixed staging package target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python - <<'PY'
|
||||
import os
|
||||
|
||||
from ops import gcp_leoclean_nosend_package as package
|
||||
|
||||
assert package.PROJECT == "teleo-501523"
|
||||
assert package.PLATFORM == "linux/amd64"
|
||||
assert os.environ["IMAGE_REPOSITORY"] == package.IMAGE_REPOSITORY
|
||||
assert package.IMAGE_REPOSITORY == "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging"
|
||||
PY
|
||||
|
||||
- name: Pull digest-pinned OCI bases
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull --platform=linux/amd64 \
|
||||
"ghcr.io/astral-sh/uv:0.9.30@sha256:538e0b39736e7feae937a65983e49d2ab75e1559d35041f9878b7b7e51de91e4"
|
||||
docker pull --platform=linux/amd64 \
|
||||
"docker.io/library/python:3.11.9-slim-bookworm@sha256:8fb099199b9f2d70342674bd9dbccd3ed03a258f26bbd1d556822c6dfc60c317"
|
||||
docker pull --platform=linux/amd64 \
|
||||
"docker.io/library/postgres:16.14-bookworm@sha256:92620daddcd947f8d5ab5ba66e848702fe443d87fed30c4cea8e389fd78dfc55"
|
||||
|
||||
- name: Compile and release-verify exact runtime artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 "${RELEASE_ROOT}" "${RELEASE_ROOT}/evidence"
|
||||
LEOCLEAN_NOSEND_RESULTS_DIR="${RELEASE_ROOT}/evidence" \
|
||||
LEOCLEAN_NOSEND_RECEIPT="${RUNTIME_RECEIPT}" \
|
||||
LEOCLEAN_NOSEND_ARTIFACT_OUTPUT="${RUNTIME_ARTIFACT}" \
|
||||
scripts/run_leoclean_nosend_runtime_canary.sh
|
||||
|
||||
- name: Smoke exact no-send OCI surface
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python scripts/run_gcp_leoclean_nosend_oci_smoke.py \
|
||||
--repo-root "${GITHUB_WORKSPACE}" \
|
||||
--artifact "${RUNTIME_ARTIFACT}" \
|
||||
--runtime-receipt "${RUNTIME_RECEIPT}" \
|
||||
--output "${OCI_SMOKE_RECEIPT}"
|
||||
|
||||
- name: Prepare exact candidate context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
identity_contract="${RELEASE_ROOT}/identity-runtime-contract.json"
|
||||
identity_manifest="${RELEASE_ROOT}/identity-manifest.json"
|
||||
identity_bundle="${RELEASE_ROOT}/identity"
|
||||
|
||||
python ops/gcp_leoclean_nosend_package.py identity-contract \
|
||||
--repo-root "${GITHUB_WORKSPACE}" \
|
||||
--artifact "${RUNTIME_ARTIFACT}" \
|
||||
--receipt "${RUNTIME_RECEIPT}" \
|
||||
--output "${identity_contract}"
|
||||
python ops/gcp_leoclean_nosend_package.py generate-identity-manifest \
|
||||
--repo-root "${GITHUB_WORKSPACE}" \
|
||||
--artifact "${RUNTIME_ARTIFACT}" \
|
||||
--receipt "${RUNTIME_RECEIPT}" \
|
||||
--database-fingerprint fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json \
|
||||
--constitution fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json \
|
||||
--database-identity fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json \
|
||||
--identity-source-root "${GITHUB_WORKSPACE}" \
|
||||
--output "${identity_manifest}"
|
||||
python ops/gcp_leoclean_nosend_package.py compile-identity \
|
||||
--repo-root "${GITHUB_WORKSPACE}" \
|
||||
--artifact "${RUNTIME_ARTIFACT}" \
|
||||
--receipt "${RUNTIME_RECEIPT}" \
|
||||
--identity-manifest "${identity_manifest}" \
|
||||
--identity-source-root "${GITHUB_WORKSPACE}" \
|
||||
--output "${identity_bundle}"
|
||||
python ops/gcp_leoclean_nosend_package.py prepare \
|
||||
--repo-root "${GITHUB_WORKSPACE}" \
|
||||
--artifact "${RUNTIME_ARTIFACT}" \
|
||||
--receipt "${RUNTIME_RECEIPT}" \
|
||||
--identity "${identity_bundle}" \
|
||||
--identity-source-root "${GITHUB_WORKSPACE}" \
|
||||
--output "${IMAGE_CONTEXT}"
|
||||
|
||||
python - "${OCI_SMOKE_RECEIPT}" "${IMAGE_CONTEXT}/image-input.json" "${GITHUB_SHA}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
smoke = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
image_input = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||
revision = sys.argv[3]
|
||||
assert smoke["status"] == "pass"
|
||||
assert smoke["cleanup"]["status"] == "pass"
|
||||
assert smoke["teleo_git_head"] == revision
|
||||
assert image_input["runtime"]["teleo_git_head"] == revision
|
||||
assert smoke["input_sha256"] == image_input["input_sha256"]
|
||||
assert smoke["identity_sha256"] == image_input["identity"]["bundle_sha256"]
|
||||
PY
|
||||
|
||||
- name: Initialize pre-mutation publish outcome
|
||||
id: init_outcome
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 "$(dirname "${PUBLISH_OUTCOME}")"
|
||||
python ops/gcp_leoclean_nosend_package.py init-outcome \
|
||||
--image-input "${IMAGE_CONTEXT}/image-input.json" \
|
||||
--output-journal "${PUBLISH_OUTCOME}"
|
||||
|
||||
- name: Authenticate to Google Cloud through WIF
|
||||
id: auth
|
||||
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed
|
||||
with:
|
||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.ARTIFACT_SERVICE_ACCOUNT }}
|
||||
create_credentials_file: true
|
||||
export_environment_variables: true
|
||||
|
||||
- name: Set up exact gcloud
|
||||
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f
|
||||
with:
|
||||
version: "550.0.0"
|
||||
|
||||
- name: Require immutable fixed Artifact Registry repository
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repository_readback="${RUNNER_TEMP}/leoclean-nosend-repository.json"
|
||||
umask 077
|
||||
test "$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" = \
|
||||
"${ARTIFACT_SERVICE_ACCOUNT}"
|
||||
gcloud artifacts repositories describe "${ARTIFACT_REPOSITORY}" \
|
||||
--project "${PROJECT_ID}" \
|
||||
--location "${REGION}" \
|
||||
--format json >"${repository_readback}"
|
||||
python - "${repository_readback}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repository = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
assert repository["name"] == (
|
||||
"projects/teleo-501523/locations/europe-west6/repositories/teleo"
|
||||
)
|
||||
assert repository["format"] == "DOCKER"
|
||||
assert repository["dockerConfig"]["immutableTags"] is True
|
||||
PY
|
||||
rm -f -- "${repository_readback}"
|
||||
|
||||
- name: Configure private Artifact Registry Docker auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 "${DOCKER_CONFIG}"
|
||||
gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
||||
test -f "${DOCKER_CONFIG}/config.json"
|
||||
test ! -L "${DOCKER_CONFIG}/config.json"
|
||||
chmod 0600 "${DOCKER_CONFIG}/config.json"
|
||||
|
||||
- name: Build and push one immutable candidate
|
||||
id: build_push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 "$(dirname "${BUILD_PUSH_RECEIPT}")"
|
||||
python ops/gcp_leoclean_nosend_package.py build-push \
|
||||
--repo-root "${GITHUB_WORKSPACE}" \
|
||||
--image-context "${IMAGE_CONTEXT}" \
|
||||
--docker-binary /usr/bin/docker \
|
||||
--docker-host unix:///var/run/docker.sock \
|
||||
--docker-config "${DOCKER_CONFIG}" \
|
||||
--gcloud-binary "$(command -v gcloud)" \
|
||||
--output-receipt "${BUILD_PUSH_RECEIPT}" \
|
||||
--outcome-journal "${PUBLISH_OUTCOME}" \
|
||||
--execute-staging-push
|
||||
|
||||
- name: Finalize and validate release v3 bundle
|
||||
id: finalize
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 "$(dirname "${RELEASE_BUNDLE}")"
|
||||
python ops/gcp_leoclean_nosend_package.py finalize \
|
||||
--image-input "${IMAGE_CONTEXT}/image-input.json" \
|
||||
--build-push-receipt "${BUILD_PUSH_RECEIPT}" \
|
||||
--docker-binary /usr/bin/docker \
|
||||
--docker-host unix:///var/run/docker.sock \
|
||||
--docker-config "${DOCKER_CONFIG}" \
|
||||
--output-bundle "${RELEASE_BUNDLE}"
|
||||
python ops/gcp_leoclean_nosend_package.py validate \
|
||||
--release "${RELEASE_BUNDLE}/release.json"
|
||||
|
||||
python - "${BUILD_PUSH_RECEIPT}" "${RELEASE_BUNDLE}/release.json" "${OCI_SMOKE_RECEIPT}" "${GITHUB_SHA}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
receipt = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
release = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||
smoke = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8"))
|
||||
revision = sys.argv[4]
|
||||
assert receipt["schema"] == "livingip.leocleanNoSendBuildPushReceipt.v1"
|
||||
assert release["schema"] == "livingip.leocleanNoSendRelease.v3"
|
||||
assert release["build_push_receipt"] == receipt
|
||||
assert release["image_input"]["runtime"]["teleo_git_head"] == revision
|
||||
assert release["image_input"]["input_sha256"] == smoke["input_sha256"]
|
||||
assert release["image"]["reference"].startswith(
|
||||
"europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging@sha256:"
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Remove ephemeral Docker authentication
|
||||
id: cleanup
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf -- "${DOCKER_CONFIG}"
|
||||
test ! -e "${DOCKER_CONFIG}"
|
||||
test ! -L "${DOCKER_CONFIG}"
|
||||
|
||||
- name: Finalize publish outcome
|
||||
if: always() && steps.init_outcome.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python ops/gcp_leoclean_nosend_package.py complete-outcome \
|
||||
--image-input "${IMAGE_CONTEXT}/image-input.json" \
|
||||
--outcome-journal "${PUBLISH_OUTCOME}" \
|
||||
--build-push-receipt "${BUILD_PUSH_RECEIPT}" \
|
||||
--release "${RELEASE_BUNDLE}/release.json" \
|
||||
--build-outcome "${{ steps.build_push.outcome }}" \
|
||||
--finalize-outcome "${{ steps.finalize.outcome }}" \
|
||||
--cleanup-outcome "${{ steps.cleanup.outcome }}"
|
||||
|
||||
- name: Upload exact available build and release evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: leoclean-nosend-release-${{ github.sha }}
|
||||
path: |
|
||||
${{ runner.temp }}/leoclean-nosend-release/evidence/runtime-receipt.json
|
||||
${{ runner.temp }}/leoclean-nosend-release/evidence/oci-smoke.json
|
||||
${{ runner.temp }}/leoclean-nosend-release/evidence/publish-outcome.json
|
||||
${{ runner.temp }}/leoclean-nosend-release/receipts/build-push-receipt.json
|
||||
${{ runner.temp }}/leoclean-nosend-release/final/release-v3/release.json
|
||||
${{ runner.temp }}/leoclean-nosend-release/final/release-v3/leoclean-gcp-nosend.service
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
330
.github/workflows/gcp-observatory-read-adapter.yml
vendored
330
.github/workflows/gcp-observatory-read-adapter.yml
vendored
|
|
@ -1,330 +0,0 @@
|
|||
name: gcp-observatory-read-adapter
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: Build, preflight, or deploy the protected staging service and run live receipts
|
||||
required: true
|
||||
default: build_only
|
||||
type: choice
|
||||
options:
|
||||
- build_only
|
||||
- preflight_staging
|
||||
- deploy_staging
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: gcp-observatory-read-adapter-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
REGION: europe-west6
|
||||
ZONE: europe-west6-a
|
||||
REPOSITORY: teleo
|
||||
IMAGE_NAME: observatory-read-adapter
|
||||
SERVICE_NAME: observatory-read-adapter-staging
|
||||
RUNTIME_SERVICE_ACCOUNT: sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com
|
||||
BUILD_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||
DEPLOY_SERVICE_ACCOUNT: sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com
|
||||
DB_IAM_USER: sa-observatory-read-adapter@teleo-501523.iam
|
||||
API_KEY_SECRET: observatory-read-api-key-staging
|
||||
BUILD_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
DEPLOY_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/observatory-read-adapter-main
|
||||
EXPECTED_WORKFLOW_REF: living-ip/teleo-infrastructure/.github/workflows/gcp-observatory-read-adapter.yml@refs/heads/main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Test, build, and push adapter image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
image_ref: ${{ steps.image.outputs.image_ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Run focused adapter tests
|
||||
run: |
|
||||
python -m pip install -e '.[dev]'
|
||||
python -m pytest \
|
||||
tests/test_observatory_read_adapter.py \
|
||||
tests/test_observatory_read_adapter_gcp_plan.py \
|
||||
-q
|
||||
python -m ruff check \
|
||||
observatory_read_adapter \
|
||||
ops/check_observatory_read_adapter_gcp_preflight.py \
|
||||
ops/plan_observatory_read_adapter_gcp.py \
|
||||
tests/test_observatory_read_adapter.py \
|
||||
tests/test_observatory_read_adapter_gcp_plan.py
|
||||
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
workload_identity_provider: ${{ env.BUILD_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.BUILD_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v3
|
||||
with:
|
||||
project_id: ${{ env.PROJECT_ID }}
|
||||
|
||||
- name: Configure Artifact Registry
|
||||
run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
||||
|
||||
- id: image
|
||||
name: Build, smoke, and push run-unique immutable image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${tag}"
|
||||
docker build -f Dockerfile.observatory-read-adapter -t "${image}" .
|
||||
docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \
|
||||
"${image}" python -m compileall -q /app/observatory_read_adapter
|
||||
docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")'
|
||||
docker push "${image}"
|
||||
digest="$(gcloud artifacts docker images describe "${image}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--format='value(image_summary.digest)')"
|
||||
[[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
image_ref="${image}@${digest}"
|
||||
printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'revision=%s\nimage_ref=%s\n' "${GITHUB_SHA}" "${image_ref}" > observatory-adapter-image.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: observatory-adapter-image
|
||||
path: observatory-adapter-image.txt
|
||||
if-no-files-found: error
|
||||
|
||||
preflight-staging:
|
||||
name: Verify private staging prerequisites
|
||||
if: ${{ inputs.action != 'build_only' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize fail-closed preflight receipt
|
||||
run: |
|
||||
python3 ops/check_observatory_read_adapter_gcp_preflight.py \
|
||||
--initialize-fail-closed \
|
||||
--output observatory-read-adapter-preflight.json
|
||||
|
||||
- name: Enforce reviewed main-only staging path
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${GITHUB_REF}" == "refs/heads/main" ]]
|
||||
[[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]]
|
||||
[[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]
|
||||
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
workload_identity_provider: ${{ env.DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v3
|
||||
with:
|
||||
project_id: ${{ env.PROJECT_ID }}
|
||||
|
||||
- name: Run adapter-specific read-only GCP preflight
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 ops/check_observatory_read_adapter_gcp_preflight.py \
|
||||
--image-ref "${IMAGE_REF}" \
|
||||
--output observatory-read-adapter-preflight.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: observatory-read-adapter-preflight
|
||||
path: observatory-read-adapter-preflight.json
|
||||
if-no-files-found: error
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy and prove staging read path
|
||||
if: ${{ inputs.action == 'deploy_staging' }}
|
||||
needs:
|
||||
- build
|
||||
- preflight-staging
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enforce reviewed main-only staging path
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${GITHUB_REF}" == "refs/heads/main" ]]
|
||||
[[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]]
|
||||
[[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]
|
||||
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
workload_identity_provider: ${{ env.DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v3
|
||||
with:
|
||||
project_id: ${{ env.PROJECT_ID }}
|
||||
|
||||
- id: deploy
|
||||
name: Deploy bounded GCP staging service
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api_key_version="$(gcloud secrets versions list "${API_KEY_SECRET}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--filter='state=ENABLED' \
|
||||
--sort-by='~createTime' \
|
||||
--limit=1 \
|
||||
--format='value(name)')"
|
||||
api_key_version="${api_key_version##*/}"
|
||||
[[ "${api_key_version}" =~ ^[0-9]+$ ]]
|
||||
gcloud run deploy "${SERVICE_NAME}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--region="${REGION}" \
|
||||
--image="${IMAGE_REF}" \
|
||||
--execution-environment=gen2 \
|
||||
--service-account="${RUNTIME_SERVICE_ACCOUNT}" \
|
||||
--network=teleo-staging-net \
|
||||
--subnet=teleo-staging-europe-west6 \
|
||||
--vpc-egress=private-ranges-only \
|
||||
--ingress=all \
|
||||
--no-invoker-iam-check \
|
||||
--port=8080 \
|
||||
--cpu=1 \
|
||||
--memory=512Mi \
|
||||
--concurrency=8 \
|
||||
--max-instances=2 \
|
||||
--timeout=15 \
|
||||
--set-secrets="OBSERVATORY_API_KEY=${API_KEY_SECRET}:${api_key_version}" \
|
||||
--set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},CLOUD_SQL_INSTANCE=${PROJECT_ID}:${REGION}:teleo-pgvector-standby,DB_NAME=teleo_canonical,DB_IAM_USER=${DB_IAM_USER},DB_AUTHORIZATION_ROLE=kb_observatory_read,SERVICE_REVISION=${GITHUB_SHA}" \
|
||||
--labels="livingip-tier=gcp-staging,livingip-surface=observatory-read-adapter" \
|
||||
--quiet
|
||||
printf 'api_key_version=%s\n' "${api_key_version}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Capture positive and negative live receipts
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY_VERSION: ${{ steps.deploy.outputs.api_key_version }}
|
||||
EXPECTED_IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
service_json="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format=json)"
|
||||
service_url="$(jq -r '.status.url // empty' <<<"${service_json}")"
|
||||
revision="$(jq -r '.status.latestReadyRevisionName // empty' <<<"${service_json}")"
|
||||
runtime_service_account="$(jq -r '.spec.template.spec.serviceAccountName // .template.serviceAccount // empty' <<<"${service_json}")"
|
||||
deployed_image="$(jq -r '.spec.template.spec.containers[0].image // .template.containers[0].image // empty' <<<"${service_json}")"
|
||||
deployed_secret_version="$(jq -r '
|
||||
[
|
||||
.spec.template.spec.containers[]?.env[]?
|
||||
| select(.name == "OBSERVATORY_API_KEY")
|
||||
| .valueFrom.secretKeyRef.key
|
||||
][0] // [
|
||||
.template.containers[]?.env[]?
|
||||
| select(.name == "OBSERVATORY_API_KEY")
|
||||
| .valueSource.secretKeyRef.version
|
||||
][0] // empty
|
||||
' <<<"${service_json}")"
|
||||
expected_digest="${EXPECTED_IMAGE_REF##*@}"
|
||||
api_key="$(gcloud secrets versions access "${API_KEY_VERSION}" --secret="${API_KEY_SECRET}" --project="${PROJECT_ID}")"
|
||||
test -n "${service_url}"
|
||||
test -n "${revision}"
|
||||
test "${runtime_service_account}" = "${RUNTIME_SERVICE_ACCOUNT}"
|
||||
test "${deployed_secret_version}" = "${API_KEY_VERSION}"
|
||||
[[ "${deployed_image}" == "${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}@${expected_digest}" ]]
|
||||
test -n "${api_key}"
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
--header "X-Api-Key: ${api_key}" \
|
||||
"${service_url}/v1/claims/sample" > positive.json
|
||||
jq -e \
|
||||
--arg project_id "${PROJECT_ID}" \
|
||||
--arg cloud_sql_instance "${PROJECT_ID}:${REGION}:teleo-pgvector-standby" \
|
||||
--arg db_iam_user "${DB_IAM_USER}" \
|
||||
--arg git_sha "${GITHUB_SHA}" '
|
||||
.schema == "livingip.observatory-canonical-claim.v1"
|
||||
and .read_only == true
|
||||
and .provenance.gcp_project == $project_id
|
||||
and .provenance.cloud_sql_instance == $cloud_sql_instance
|
||||
and .provenance.database == "teleo_canonical"
|
||||
and .provenance.database_principal == $db_iam_user
|
||||
and .provenance.authorization_role == "kb_observatory_read"
|
||||
and .provenance.transaction_read_only == true
|
||||
and .provenance.write_privileges_denied == true
|
||||
and .provenance.service_revision == $git_sha
|
||||
and (.canonical.evidence | length) > 0
|
||||
and .proposal_ledger.distinct_from_canonical == true
|
||||
' positive.json >/dev/null
|
||||
|
||||
anonymous_status="$(curl --silent --output anonymous.json --write-out '%{http_code}' "${service_url}/v1/claims/sample")"
|
||||
write_status="$(curl --silent --output write.json --write-out '%{http_code}' \
|
||||
--request POST --header "X-Api-Key: ${api_key}" --header 'Content-Type: application/json' \
|
||||
--data '{"status":"applied"}' "${service_url}/v1/claims/sample")"
|
||||
test "${anonymous_status}" = 401
|
||||
test "${write_status}" = 405
|
||||
curl --fail --silent --show-error \
|
||||
--header "X-Api-Key: ${api_key}" \
|
||||
"${service_url}/v1/claims/sample" > after-write-attempt.json
|
||||
cmp <(jq -S . positive.json) <(jq -S . after-write-attempt.json)
|
||||
|
||||
jq -f ops/redact_observatory_read_adapter_receipt.jq \
|
||||
positive.json > positive-redacted.json
|
||||
|
||||
jq -n \
|
||||
--arg service_url "${service_url}" \
|
||||
--arg revision "${revision}" \
|
||||
--arg git_sha "${GITHUB_SHA}" \
|
||||
--arg runtime_service_account "${runtime_service_account}" \
|
||||
--arg database_principal "${DB_IAM_USER}" \
|
||||
--arg deployed_image "${deployed_image}" \
|
||||
--arg api_key_secret_version "${API_KEY_VERSION}" \
|
||||
--argjson positive "$(cat positive-redacted.json)" \
|
||||
--arg anonymous_status "${anonymous_status}" \
|
||||
--arg write_status "${write_status}" \
|
||||
'{
|
||||
schema: "livingip.observatory-read-adapter-live-receipt.v1",
|
||||
required_tier: "T3_live_readonly",
|
||||
service_url: $service_url,
|
||||
revision: $revision,
|
||||
git_sha: $git_sha,
|
||||
runtime_service_account: $runtime_service_account,
|
||||
database_principal: $database_principal,
|
||||
deployed_image: $deployed_image,
|
||||
api_key_secret_version: $api_key_secret_version,
|
||||
positive: $positive,
|
||||
negative: {
|
||||
anonymous_http_status: ($anonymous_status | tonumber),
|
||||
authenticated_post_http_status: ($write_status | tonumber),
|
||||
canonical_state_unchanged_after_post: true,
|
||||
database_write_privileges_denied: $positive.provenance.write_privileges_denied
|
||||
},
|
||||
production_repoint_executed: false
|
||||
}' > observatory-read-adapter-live-receipt.json
|
||||
unset api_key
|
||||
rm -f positive.json positive-redacted.json after-write-attempt.json anonymous.json write.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: observatory-read-adapter-live-receipt
|
||||
path: observatory-read-adapter-live-receipt.json
|
||||
if-no-files-found: error
|
||||
132
.github/workflows/gcp-readiness.yml
vendored
132
.github/workflows/gcp-readiness.yml
vendored
|
|
@ -1,132 +0,0 @@
|
|||
name: gcp-readiness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service_account:
|
||||
description: GCP service account to impersonate for the read-only readiness probe
|
||||
required: false
|
||||
default: sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com
|
||||
restore_canary_capsule_b64:
|
||||
description: Optional base64-encoded non-secret SQLite-to-Postgres restore canary capsule
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: gcp-readiness-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
READINESS_SERVICE_ACCOUNT: ${{ inputs.service_account || 'sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com' }}
|
||||
RESTORE_CANARY_CAPSULE_B64: ${{ inputs.restore_canary_capsule_b64 || '' }}
|
||||
READINESS_ARTIFACT_DIR: gcp-readiness-artifacts
|
||||
|
||||
jobs:
|
||||
readiness:
|
||||
name: Read-only GCP readiness probe
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.READINESS_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Install optional restore canary capsule
|
||||
if: ${{ env.RESTORE_CANARY_CAPSULE_B64 != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${READINESS_ARTIFACT_DIR}"
|
||||
capsule_path="${READINESS_ARTIFACT_DIR}/restore-canary-capsule.json"
|
||||
printf '%s' "${RESTORE_CANARY_CAPSULE_B64}" | base64 --decode > "${capsule_path}"
|
||||
python3 -m json.tool "${capsule_path}" >/dev/null
|
||||
echo "TELEO_GCP_RESTORE_CANARY_CAPSULE=${capsule_path}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Capture gcloud identity
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "${READINESS_ARTIFACT_DIR}"
|
||||
{
|
||||
echo "project=${PROJECT_ID}"
|
||||
echo "service_account=${READINESS_SERVICE_ACCOUNT}"
|
||||
echo "workflow_ref=${GITHUB_REF}"
|
||||
echo "revision=${GITHUB_SHA}"
|
||||
} > "${READINESS_ARTIFACT_DIR}/context.txt"
|
||||
gcloud auth list --format=json > "${READINESS_ARTIFACT_DIR}/gcloud-auth-list.json" 2> "${READINESS_ARTIFACT_DIR}/gcloud-auth-list.stderr"
|
||||
echo "$?" > "${READINESS_ARTIFACT_DIR}/gcloud-auth-list.exitcode"
|
||||
gcloud config list --format=json > "${READINESS_ARTIFACT_DIR}/gcloud-config-list.json" 2> "${READINESS_ARTIFACT_DIR}/gcloud-config-list.stderr"
|
||||
echo "$?" > "${READINESS_ARTIFACT_DIR}/gcloud-config-list.exitcode"
|
||||
|
||||
- name: Run readiness checker
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "${READINESS_ARTIFACT_DIR}"
|
||||
python3 ops/check_gcp_infra_readiness.py \
|
||||
> "${READINESS_ARTIFACT_DIR}/gcp-infra-readiness.json" \
|
||||
2> "${READINESS_ARTIFACT_DIR}/gcp-infra-readiness.stderr"
|
||||
echo "$?" > "${READINESS_ARTIFACT_DIR}/gcp-infra-readiness.exitcode"
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
base = Path(os.environ["READINESS_ARTIFACT_DIR"])
|
||||
report = {
|
||||
"artifact": "github_wif_gcp_readiness_probe",
|
||||
"checker_exitcode": (base / "gcp-infra-readiness.exitcode").read_text().strip(),
|
||||
"stderr_tail": (base / "gcp-infra-readiness.stderr").read_text()[-2000:],
|
||||
}
|
||||
try:
|
||||
payload = json.loads((base / "gcp-infra-readiness.json").read_text() or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
report["json_parse_error"] = str(exc)
|
||||
payload = {}
|
||||
if payload:
|
||||
report["pass_count"] = payload.get("pass_count")
|
||||
report["blocked_count"] = payload.get("blocked_count")
|
||||
report["fail_count"] = payload.get("fail_count")
|
||||
report["checks"] = [
|
||||
{
|
||||
"name": check.get("name"),
|
||||
"status": check.get("status"),
|
||||
"required_tier": check.get("required_tier"),
|
||||
"current_tier": check.get("current_tier"),
|
||||
"detail": (check.get("detail") or "")[:500],
|
||||
}
|
||||
for check in payload.get("checks", [])
|
||||
]
|
||||
(base / "gcp-readiness-summary.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
PY
|
||||
|
||||
- name: Upload readiness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gcp-readiness
|
||||
path: ${{ env.READINESS_ARTIFACT_DIR }}/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Enforce readiness result
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
code="$(cat "${READINESS_ARTIFACT_DIR}/gcp-infra-readiness.exitcode" 2>/dev/null || echo 1)"
|
||||
if [ "${code}" != "0" ]; then
|
||||
echo "GCP readiness checker failed with exit code ${code}. See the uploaded gcp-readiness artifact for exact failed checks."
|
||||
exit "${code}"
|
||||
fi
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -10,7 +10,6 @@ __pycache__/
|
|||
|
||||
# Secrets (never commit)
|
||||
secrets/
|
||||
gha-creds-*.json
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
|
@ -21,8 +20,6 @@ logs/
|
|||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.crabbox/
|
||||
.crabbox-results/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
|
|
@ -33,6 +30,3 @@ build/
|
|||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Hermes session artifacts
|
||||
ops/sessions/
|
||||
|
|
|
|||
79
CODEOWNERS
79
CODEOWNERS
|
|
@ -1,79 +0,0 @@
|
|||
# teleo-infrastructure ownership map
|
||||
# Each path has ONE owning agent. Owner = accountable for correctness + reviews changes.
|
||||
# Format: <pattern> <owner>
|
||||
|
||||
# Pipeline daemon — entry points
|
||||
/teleo-pipeline.py @ship
|
||||
/reweave.py @ship
|
||||
|
||||
# Pipeline library — shared Python package
|
||||
/lib/config.py @ship
|
||||
/lib/db.py @ship
|
||||
/lib/connect.py @ship
|
||||
/lib/log.py @ship
|
||||
/lib/forgejo.py @ship
|
||||
/lib/breaker.py @ship
|
||||
/lib/worktree_lock.py @ship
|
||||
/lib/domains.py @ship
|
||||
/lib/costs.py @ship
|
||||
/lib/llm.py @ship
|
||||
/lib/merge.py @ship
|
||||
/lib/cascade.py @ship
|
||||
/lib/cross_domain.py @ship
|
||||
/lib/validate.py @ship
|
||||
/lib/stale_pr.py @ship
|
||||
/lib/watchdog.py @ship
|
||||
/lib/feedback.py @ship
|
||||
/lib/fixer.py @ship
|
||||
/lib/substantive_fixer.py @ship
|
||||
/lib/dedup.py @ship
|
||||
|
||||
/lib/extract.py @epimetheus
|
||||
/lib/extraction_prompt.py @epimetheus
|
||||
/lib/post_extract.py @epimetheus
|
||||
/lib/pre_screen.py @epimetheus
|
||||
/lib/entity_batch.py @epimetheus
|
||||
/lib/entity_queue.py @epimetheus
|
||||
|
||||
/lib/evaluate.py @leo
|
||||
/lib/analytics.py @leo
|
||||
/lib/attribution.py @leo
|
||||
|
||||
/lib/health.py @argus
|
||||
/lib/search.py @argus
|
||||
/lib/claim_index.py @argus
|
||||
/lib/digest.py @argus
|
||||
|
||||
# Diagnostics — monitoring dashboard
|
||||
/diagnostics/ @argus
|
||||
|
||||
# Telegram bot
|
||||
/telegram/ @ship
|
||||
|
||||
# Deployment automation
|
||||
/deploy/ @ship
|
||||
|
||||
# Systemd service definitions
|
||||
/systemd/ @ship
|
||||
|
||||
# Agent state management
|
||||
/agent-state/ @ship
|
||||
|
||||
# Research orchestration
|
||||
/research/ @ship
|
||||
|
||||
# Hermes agent
|
||||
/hermes-agent/ @ship
|
||||
|
||||
# One-off scripts and migrations
|
||||
/scripts/ @ship
|
||||
|
||||
# Test suite
|
||||
/tests/ @ganymede
|
||||
|
||||
# Documentation
|
||||
/docs/ shared
|
||||
|
||||
# Config
|
||||
/pyproject.toml @ship
|
||||
/.gitignore @ship
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIPELINE_BASE=/opt/teleo-eval
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md /app/
|
||||
COPY lib /app/lib
|
||||
COPY tests /app/tests
|
||||
COPY scripts /app/scripts
|
||||
COPY fixtures /app/fixtures
|
||||
COPY schemas /app/schemas
|
||||
COPY systemd /app/systemd
|
||||
COPY deploy /app/deploy
|
||||
COPY diagnostics /app/diagnostics
|
||||
COPY telegram /app/telegram
|
||||
COPY teleo-pipeline.py /app/teleo-pipeline.py
|
||||
COPY docker/gcp-staging-smoke.sh /usr/local/bin/gcp-staging-smoke
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -e ".[dev]" \
|
||||
&& chmod +x /usr/local/bin/gcp-staging-smoke
|
||||
|
||||
CMD ["/usr/local/bin/gcp-staging-smoke"]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PORT=8080
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md /app/
|
||||
COPY observatory_read_adapter /app/observatory_read_adapter
|
||||
COPY lib /app/lib
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir ".[observatory]" \
|
||||
&& addgroup --system --gid 10001 adapter \
|
||||
&& adduser --system --uid 10001 --ingroup adapter --no-create-home adapter
|
||||
|
||||
USER 10001:10001
|
||||
|
||||
CMD ["python", "-m", "observatory_read_adapter"]
|
||||
150
README.md
150
README.md
|
|
@ -1,150 +0,0 @@
|
|||
# teleo-infrastructure
|
||||
|
||||
This repo runs the pipeline that processes contributions into the
|
||||
[teleo-codex](https://github.com/living-ip/teleo-codex) knowledge base.
|
||||
|
||||
Every claim on `main` has been extracted from a source, validated for schema
|
||||
and duplicates, evaluated by at least two independent reviewers, and merged
|
||||
through an event-sourced audit log. The whole flow is an async Python daemon
|
||||
talking to a Forgejo git server, an SQLite WAL state store, OpenRouter (for
|
||||
most LLM calls), and the Anthropic Claude CLI (for Opus deep reviews).
|
||||
|
||||
**Production state** (live):
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Claims merged into `main` | 1,546 across 13 domains |
|
||||
| PRs merged through the pipeline | 1,975 |
|
||||
| Merge throughput (last 7d) | 508 PRs (~73/day) |
|
||||
| Review approval rate | 94% |
|
||||
| Cost per merged claim (last 30d) | $0.10 incl. extract + triage + multi-tier review |
|
||||
| Production agents | 6 (rio, theseus, leo, vida, astra, clay) |
|
||||
|
||||
## Pipeline
|
||||
|
||||
Concurrent stage loops in a single daemon (`teleo-pipeline.py`), coordinated
|
||||
by SQLite. Circuit breakers cap costs, retry budgets cap attempts, and merges
|
||||
are serialized per-domain to avoid cross-PR conflicts.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Inbox["inbox/queue/"] --> Extract
|
||||
Extract["Extract<br/>(Sonnet 4.5)"] --> Validate
|
||||
Validate["Validate<br/>(tier 0, $0)"] --> Evaluate
|
||||
Evaluate["Evaluate<br/>(tiered, multi-model)"] --> Merge
|
||||
Merge["Merge<br/>(Forgejo, domain-serial)"] --> Effects
|
||||
Effects["Effects<br/>cascade · backlinks · reciprocal edges"]
|
||||
```
|
||||
|
||||
If any reviewer rejects, the PR gets a structured rationale and either
|
||||
re-extraction guidance (for fixable issues) or a terminal close (for
|
||||
scope or duplicate problems). Approved merges trigger downstream effects:
|
||||
|
||||
- **Cascade** — agents whose beliefs/positions depend on the changed claim get inbox notifications
|
||||
- **Bidirectional provenance** — `sourced_from:` is stamped on each claim at extraction; the source's `claims_extracted:` list is updated post-merge
|
||||
- **Reciprocal edges** — when a new claim has `supports: [X]`, X's frontmatter is updated with `supports: [new]`
|
||||
- **Cross-domain index** — entity mentions across domain boundaries are logged for silo detection
|
||||
|
||||
## Multi-agent review
|
||||
|
||||
Reviews aren't free. Tier classification is deterministic where possible
|
||||
(changes to `core/` or `foundations/` always go Deep) and otherwise picked
|
||||
by Haiku based on PR scope. Last 30d distribution: 76% Standard, 21% Light,
|
||||
2% Deep.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PR[New PR] --> Classify{Classify}
|
||||
Classify -->|"core/, foundations/, challenged"| Deep
|
||||
Classify -->|default| Standard
|
||||
Classify -->|single claim, low risk| Light
|
||||
Light["Light tier<br/>Domain agent only"] --> Result
|
||||
Standard["Standard tier<br/>Domain agent + Leo (Sonnet 4.5)"] --> Result
|
||||
Deep["Deep tier<br/>Domain agent + Leo (Opus)"] --> Result
|
||||
Result{Both approve?}
|
||||
Result -->|yes| MergeOK[Merge]
|
||||
Result -->|no| Reject[Structured rejection<br/>+ re-extract guidance]
|
||||
```
|
||||
|
||||
Domain agents bring domain expertise: **Rio** (internet-finance), **Vida**
|
||||
(health), **Astra** (space-development), **Clay** (entertainment),
|
||||
**Theseus** (ai-alignment). **Leo** brings cross-domain consistency on
|
||||
every PR. Disagreement between the two reviewers surfaces in `audit_log`
|
||||
and is tracked as a quality signal, not silenced.
|
||||
|
||||
Model diversity isn't cosmetic — same-family models share ~60% of their
|
||||
errors (Kim et al. ICML 2025). Pipeline mixes Haiku for triage, Gemini 2.5
|
||||
Flash for domain review, Sonnet 4.5 for Leo standard, Opus for Leo deep.
|
||||
|
||||
## Contributor flow
|
||||
|
||||
External contributors submit PRs to
|
||||
[`living-ip/teleo-codex`](https://github.com/living-ip/teleo-codex) on GitHub.
|
||||
A mirror sync (every 2 minutes) fast-forwards the PR onto Forgejo, where
|
||||
the pipeline picks it up. From there it's the same flow as agent-authored
|
||||
PRs — same tiers, same reviewers, same merge rules.
|
||||
|
||||
The contributor-facing guide is the
|
||||
[Teleo Codex contributing guide](https://github.com/living-ip/teleo-codex/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Directory | What it does |
|
||||
|-----------------|-----------------------------------------------------------|
|
||||
| `lib/` | Pipeline modules — config, db, extract, evaluate, merge, cascade |
|
||||
| `diagnostics/` | Argus monitoring dashboard (4 pages: ops, health, agents, epistemic) |
|
||||
| `telegram/` | Telegram bot that answers from the knowledge base |
|
||||
| `research/` | Nightly autonomous research sessions for domain agents |
|
||||
| `agent-state/` | File-backed state for cross-session agent continuity |
|
||||
| `deploy/` | Auto-deploy pipeline (Forgejo → working dirs → systemd) |
|
||||
| `systemd/` | Service definitions for daemon + dashboard + agents |
|
||||
| `scripts/` | Backfills and one-off migrations |
|
||||
| `tests/` | pytest suite |
|
||||
| `docs/` | Architecture specs and operational protocols |
|
||||
|
||||
## Ownership
|
||||
|
||||
Code review authority is enforced by [`CODEOWNERS`](./CODEOWNERS) — every
|
||||
file has one accountable agent. The high-level map:
|
||||
|
||||
- **Ship** — pipeline core, telegram, deploy, agent-state, research, systemd
|
||||
- **Epimetheus** — extraction (intake, entity processing, pre-screening, post-extract validation)
|
||||
- **Leo** — evaluation (claim review, analytics, attribution)
|
||||
- **Argus** — health (diagnostics dashboard, alerting, claim index, search)
|
||||
- **Ganymede** — tests (pytest suite, integration, code review gate)
|
||||
|
||||
For active sprint work and per-agent in-flight items, see each agent's
|
||||
status report in their Pentagon profile.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e ".[dev]"
|
||||
.venv/bin/python -m pytest
|
||||
```
|
||||
|
||||
## Leo / Teleo Operator Onboarding
|
||||
|
||||
Start with the repo-native skill router, not historical chat or a runtime mirror:
|
||||
|
||||
```bash
|
||||
.agents/skills/teleo-leo-onboarding/scripts/validate_skill_pack.py --root .
|
||||
```
|
||||
|
||||
Then read `.agents/skills/teleo-leo-onboarding/SKILL.md` and
|
||||
`docs/reports/leo-working-state-20260709/current-truth-index.md`. Recovery work
|
||||
starts at `docs/kb-rebuild-and-recompile.md`. GitHub
|
||||
`living-ip/teleo-infrastructure` is canonical code/deployment truth; VPS/GCP
|
||||
runtime, canonical Postgres, proposal staging, Hermes memory, and retained
|
||||
proofs are separate state surfaces.
|
||||
|
||||
## Operations
|
||||
|
||||
Production deployment runs on a single VPS. Runbook, restart procedures,
|
||||
secret rotation, and on-call live in the private
|
||||
[`teleo-ops`](https://github.com/living-ip/teleo-ops) repo (request access).
|
||||
|
||||
## License
|
||||
|
||||
[TBD]
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
# Agent State Schema v1
|
||||
|
||||
File-backed durable state for teleo agents running headless on VPS.
|
||||
Survives context truncation, crash recovery, and session handoffs.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Three formats** — JSON for structured fields, JSONL for append-only logs, Markdown for context-window-friendly content
|
||||
2. **Many small files** — selective loading, crash isolation, no locks needed
|
||||
3. **Write on events** — not timers. State updates happen when something meaningful changes.
|
||||
4. **Shared-nothing writes** — each agent owns its directory. Communication via inbox files.
|
||||
5. **State ≠ Git** — state is operational (how the agent functions). Git is output (what the agent produces).
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
/opt/teleo-eval/agent-state/{agent}/
|
||||
├── report.json # Current status — read every wake
|
||||
├── tasks.json # Active task queue — read every wake
|
||||
├── session.json # Current/last session metadata
|
||||
├── memory.md # Accumulated cross-session knowledge (structured)
|
||||
├── inbox/ # Messages from other agents/orchestrator
|
||||
│ └── {uuid}.json # One file per message, atomic create
|
||||
├── journal.jsonl # Append-only session log
|
||||
└── metrics.json # Cumulative performance counters
|
||||
```
|
||||
|
||||
## File Specifications
|
||||
|
||||
### report.json
|
||||
|
||||
Written: after each meaningful action (session start, key finding, session end)
|
||||
Read: every wake, by orchestrator for monitoring
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "rio",
|
||||
"updated_at": "2026-03-31T22:00:00Z",
|
||||
"status": "idle | researching | extracting | evaluating | error",
|
||||
"summary": "Completed research session — 8 sources archived on Solana launchpad mechanics",
|
||||
"current_task": null,
|
||||
"last_session": {
|
||||
"id": "20260331-220000",
|
||||
"started_at": "2026-03-31T20:30:00Z",
|
||||
"ended_at": "2026-03-31T22:00:00Z",
|
||||
"outcome": "completed | timeout | error",
|
||||
"sources_archived": 8,
|
||||
"branch": "rio/research-2026-03-31",
|
||||
"pr_number": 247
|
||||
},
|
||||
"blocked_by": null,
|
||||
"next_priority": "Follow up on conditional AMM thread from @0xfbifemboy"
|
||||
}
|
||||
```
|
||||
|
||||
### tasks.json
|
||||
|
||||
Written: when task status changes
|
||||
Read: every wake
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "rio",
|
||||
"updated_at": "2026-03-31T22:00:00Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"type": "research | extract | evaluate | follow-up | disconfirm",
|
||||
"description": "Investigate conditional AMM mechanisms in MetaDAO v2",
|
||||
"status": "pending | active | completed | dropped",
|
||||
"priority": "high | medium | low",
|
||||
"created_at": "2026-03-31T22:00:00Z",
|
||||
"context": "Flagged in research session 2026-03-31 — @0xfbifemboy thread on conditional liquidity",
|
||||
"follow_up_from": null,
|
||||
"completed_at": null,
|
||||
"outcome": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### session.json
|
||||
|
||||
Written: at session start and session end
|
||||
Read: every wake (for continuation), by orchestrator for scheduling
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "rio",
|
||||
"session_id": "20260331-220000",
|
||||
"started_at": "2026-03-31T20:30:00Z",
|
||||
"ended_at": "2026-03-31T22:00:00Z",
|
||||
"type": "research | extract | evaluate | ad-hoc",
|
||||
"domain": "internet-finance",
|
||||
"branch": "rio/research-2026-03-31",
|
||||
"status": "running | completed | timeout | error",
|
||||
"model": "sonnet",
|
||||
"timeout_seconds": 5400,
|
||||
"research_question": "How is conditional liquidity being implemented in Solana AMMs?",
|
||||
"belief_targeted": "Markets aggregate information better than votes because skin-in-the-game creates selection pressure on beliefs",
|
||||
"disconfirmation_target": "Cases where prediction markets failed to aggregate information despite financial incentives",
|
||||
"sources_archived": 8,
|
||||
"sources_expected": 10,
|
||||
"tokens_used": null,
|
||||
"cost_usd": null,
|
||||
"errors": [],
|
||||
"handoff_notes": "Found 3 sources on conditional AMM failures — needs extraction. Also flagged @metaproph3t thread for Theseus (AI governance angle)."
|
||||
}
|
||||
```
|
||||
|
||||
### memory.md
|
||||
|
||||
Written: at session end, when learning something critical
|
||||
Read: every wake (included in research prompt context)
|
||||
|
||||
```markdown
|
||||
# Rio — Operational Memory
|
||||
|
||||
## Cross-Session Patterns
|
||||
- Conditional AMMs keep appearing across 3+ independent sources (sessions 03-28, 03-29, 03-31). This is likely a real trend, not cherry-picking.
|
||||
- @0xfbifemboy consistently produces highest-signal threads in the DeFi mechanism design space.
|
||||
|
||||
## Dead Ends (don't re-investigate)
|
||||
- Polymarket fee structure analysis (2026-03-25): fully documented in existing claims, no new angles.
|
||||
- Jupiter governance token utility (2026-03-27): vaporware, no mechanism to analyze.
|
||||
|
||||
## Open Questions
|
||||
- Is MetaDAO's conditional market maker manipulation-resistant at scale? No evidence either way yet.
|
||||
- How does futarchy handle low-liquidity markets? This is the keystone weakness.
|
||||
|
||||
## Corrections
|
||||
- Previously believed Drift protocol was pure order-book. Actually hybrid AMM+CLOB. Updated 2026-03-30.
|
||||
|
||||
## Cross-Agent Flags Received
|
||||
- Theseus (2026-03-29): "Check if MetaDAO governance has AI agent participation — alignment implications"
|
||||
- Leo (2026-03-28): "Your conditional AMM analysis connects to Astra's resource allocation claims"
|
||||
```
|
||||
|
||||
### inbox/{uuid}.json
|
||||
|
||||
Written: by other agents or orchestrator
|
||||
Read: checked on wake, deleted after processing
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msg-abc123",
|
||||
"from": "theseus",
|
||||
"to": "rio",
|
||||
"created_at": "2026-03-31T18:00:00Z",
|
||||
"type": "flag | task | question | cascade",
|
||||
"priority": "high | normal",
|
||||
"subject": "Check MetaDAO for AI agent participation",
|
||||
"body": "Found evidence that AI agents are trading on Drift — check if any are participating in MetaDAO conditional markets. Alignment implications if automated agents are influencing futarchic governance.",
|
||||
"source_ref": "theseus/research-2026-03-31",
|
||||
"expires_at": null
|
||||
}
|
||||
```
|
||||
|
||||
### journal.jsonl
|
||||
|
||||
Written: append at session boundaries
|
||||
Read: debug/audit only (never loaded into agent context by default)
|
||||
|
||||
```jsonl
|
||||
{"ts":"2026-03-31T20:30:00Z","event":"session_start","session_id":"20260331-220000","type":"research"}
|
||||
{"ts":"2026-03-31T20:35:00Z","event":"orient_complete","files_read":["identity.md","beliefs.md","reasoning.md","_map.md"]}
|
||||
{"ts":"2026-03-31T21:30:00Z","event":"sources_archived","count":5,"domain":"internet-finance"}
|
||||
{"ts":"2026-03-31T22:00:00Z","event":"session_end","outcome":"completed","sources_archived":8,"handoff":"conditional AMM failures need extraction"}
|
||||
```
|
||||
|
||||
### metrics.json
|
||||
|
||||
Written: at session end (cumulative counters)
|
||||
Read: by CI scoring system, by orchestrator for scheduling decisions
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "rio",
|
||||
"updated_at": "2026-03-31T22:00:00Z",
|
||||
"lifetime": {
|
||||
"sessions_total": 47,
|
||||
"sessions_completed": 42,
|
||||
"sessions_timeout": 3,
|
||||
"sessions_error": 2,
|
||||
"sources_archived": 312,
|
||||
"claims_proposed": 89,
|
||||
"claims_accepted": 71,
|
||||
"claims_challenged": 12,
|
||||
"claims_rejected": 6,
|
||||
"disconfirmation_attempts": 47,
|
||||
"disconfirmation_hits": 8,
|
||||
"cross_agent_flags_sent": 23,
|
||||
"cross_agent_flags_received": 15
|
||||
},
|
||||
"rolling_30d": {
|
||||
"sessions": 12,
|
||||
"sources_archived": 87,
|
||||
"claims_proposed": 24,
|
||||
"acceptance_rate": 0.83,
|
||||
"avg_sources_per_session": 7.25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### research-session.sh
|
||||
|
||||
Add these hooks:
|
||||
|
||||
1. **Pre-session** (after branch creation, before Claude launch):
|
||||
- Write `session.json` with status "running"
|
||||
- Write `report.json` with status "researching"
|
||||
- Append session_start to `journal.jsonl`
|
||||
- Include `memory.md` and `tasks.json` in the research prompt
|
||||
|
||||
2. **Post-session** (after commit, before/after PR):
|
||||
- Update `session.json` with outcome, source count, branch, PR number
|
||||
- Update `report.json` with summary and next_priority
|
||||
- Update `metrics.json` counters
|
||||
- Append session_end to `journal.jsonl`
|
||||
- Process and clean `inbox/` (mark processed messages)
|
||||
|
||||
3. **On error/timeout**:
|
||||
- Update `session.json` status to "error" or "timeout"
|
||||
- Update `report.json` with error info
|
||||
- Append error event to `journal.jsonl`
|
||||
|
||||
### Pipeline daemon (teleo-pipeline.py)
|
||||
|
||||
- Read `report.json` for all agents to build dashboard
|
||||
- Write to `inbox/` when cascade events need agent attention
|
||||
- Read `metrics.json` for scheduling decisions (deprioritize agents with high error rates)
|
||||
|
||||
### Claude research prompt
|
||||
|
||||
Add to the prompt:
|
||||
```
|
||||
### Step 0: Load Operational State (1 min)
|
||||
Read /opt/teleo-eval/agent-state/{agent}/memory.md — this is your cross-session operational memory.
|
||||
Read /opt/teleo-eval/agent-state/{agent}/tasks.json — check for pending tasks.
|
||||
Check /opt/teleo-eval/agent-state/{agent}/inbox/ for messages from other agents.
|
||||
Process any high-priority inbox items before choosing your research direction.
|
||||
```
|
||||
|
||||
## Bootstrap
|
||||
|
||||
Run `ops/agent-state/bootstrap.sh` to create directories and seed initial state for all agents.
|
||||
|
||||
## Migration from Existing State
|
||||
|
||||
- `research-journal.md` continues as-is (agent-written, in git). `memory.md` is the structured equivalent for operational state (not in git).
|
||||
- `ops/sessions/*.json` continue for backward compat. `session.json` per agent is the richer replacement.
|
||||
- `ops/queue.md` remains the human-visible task board. `tasks.json` per agent is the machine-readable equivalent.
|
||||
- Workspace flags (`~/.pentagon/workspace/collective/flag-*`) migrate to `inbox/` messages over time.
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Bootstrap agent-state directories for all teleo agents.
|
||||
# Run once on VPS: bash ops/agent-state/bootstrap.sh
|
||||
# Safe to re-run — skips existing files, only creates missing ones.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STATE_ROOT="${TELEO_STATE_ROOT:-/opt/teleo-eval/agent-state}"
|
||||
|
||||
AGENTS=("rio" "clay" "theseus" "vida" "astra" "leo")
|
||||
DOMAINS=("internet-finance" "entertainment" "ai-alignment" "health" "space-development" "grand-strategy")
|
||||
|
||||
log() { echo "[$(date -Iseconds)] $*"; }
|
||||
|
||||
for i in "${!AGENTS[@]}"; do
|
||||
AGENT="${AGENTS[$i]}"
|
||||
DOMAIN="${DOMAINS[$i]}"
|
||||
DIR="$STATE_ROOT/$AGENT"
|
||||
|
||||
log "Bootstrapping $AGENT..."
|
||||
mkdir -p "$DIR/inbox"
|
||||
|
||||
# report.json — current status
|
||||
if [ ! -f "$DIR/report.json" ]; then
|
||||
cat > "$DIR/report.json" <<EOJSON
|
||||
{
|
||||
"agent": "$AGENT",
|
||||
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"status": "idle",
|
||||
"summary": "State initialized — no sessions recorded yet.",
|
||||
"current_task": null,
|
||||
"last_session": null,
|
||||
"blocked_by": null,
|
||||
"next_priority": null
|
||||
}
|
||||
EOJSON
|
||||
log " Created report.json"
|
||||
fi
|
||||
|
||||
# tasks.json — empty task queue
|
||||
if [ ! -f "$DIR/tasks.json" ]; then
|
||||
cat > "$DIR/tasks.json" <<EOJSON
|
||||
{
|
||||
"agent": "$AGENT",
|
||||
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"tasks": []
|
||||
}
|
||||
EOJSON
|
||||
log " Created tasks.json"
|
||||
fi
|
||||
|
||||
# session.json — no session yet
|
||||
if [ ! -f "$DIR/session.json" ]; then
|
||||
cat > "$DIR/session.json" <<EOJSON
|
||||
{
|
||||
"agent": "$AGENT",
|
||||
"session_id": null,
|
||||
"started_at": null,
|
||||
"ended_at": null,
|
||||
"type": null,
|
||||
"domain": "$DOMAIN",
|
||||
"branch": null,
|
||||
"status": "idle",
|
||||
"model": null,
|
||||
"timeout_seconds": null,
|
||||
"research_question": null,
|
||||
"belief_targeted": null,
|
||||
"disconfirmation_target": null,
|
||||
"sources_archived": 0,
|
||||
"sources_expected": 0,
|
||||
"tokens_used": null,
|
||||
"cost_usd": null,
|
||||
"errors": [],
|
||||
"handoff_notes": null
|
||||
}
|
||||
EOJSON
|
||||
log " Created session.json"
|
||||
fi
|
||||
|
||||
# memory.md — empty operational memory
|
||||
if [ ! -f "$DIR/memory.md" ]; then
|
||||
cat > "$DIR/memory.md" <<EOMD
|
||||
# ${AGENT^} — Operational Memory
|
||||
|
||||
## Cross-Session Patterns
|
||||
(none yet)
|
||||
|
||||
## Dead Ends
|
||||
(none yet)
|
||||
|
||||
## Open Questions
|
||||
(none yet)
|
||||
|
||||
## Corrections
|
||||
(none yet)
|
||||
|
||||
## Cross-Agent Flags Received
|
||||
(none yet)
|
||||
EOMD
|
||||
log " Created memory.md"
|
||||
fi
|
||||
|
||||
# metrics.json — zero counters
|
||||
if [ ! -f "$DIR/metrics.json" ]; then
|
||||
cat > "$DIR/metrics.json" <<EOJSON
|
||||
{
|
||||
"agent": "$AGENT",
|
||||
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"lifetime": {
|
||||
"sessions_total": 0,
|
||||
"sessions_completed": 0,
|
||||
"sessions_timeout": 0,
|
||||
"sessions_error": 0,
|
||||
"sources_archived": 0,
|
||||
"claims_proposed": 0,
|
||||
"claims_accepted": 0,
|
||||
"claims_challenged": 0,
|
||||
"claims_rejected": 0,
|
||||
"disconfirmation_attempts": 0,
|
||||
"disconfirmation_hits": 0,
|
||||
"cross_agent_flags_sent": 0,
|
||||
"cross_agent_flags_received": 0
|
||||
},
|
||||
"rolling_30d": {
|
||||
"sessions": 0,
|
||||
"sources_archived": 0,
|
||||
"claims_proposed": 0,
|
||||
"acceptance_rate": 0.0,
|
||||
"avg_sources_per_session": 0.0
|
||||
}
|
||||
}
|
||||
EOJSON
|
||||
log " Created metrics.json"
|
||||
fi
|
||||
|
||||
# journal.jsonl — empty log
|
||||
if [ ! -f "$DIR/journal.jsonl" ]; then
|
||||
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"state_initialized\",\"schema_version\":\"1.0\"}" > "$DIR/journal.jsonl"
|
||||
log " Created journal.jsonl"
|
||||
fi
|
||||
|
||||
done
|
||||
|
||||
log "Bootstrap complete. State root: $STATE_ROOT"
|
||||
log "Agents initialized: ${AGENTS[*]}"
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
#!/bin/bash
|
||||
# lib-state.sh — Bash helpers for reading/writing agent state files.
|
||||
# Source this in pipeline scripts: source ops/agent-state/lib-state.sh
|
||||
#
|
||||
# All writes use atomic rename (write to .tmp, then mv) to prevent corruption.
|
||||
# All reads return valid JSON or empty string on missing/corrupt files.
|
||||
|
||||
STATE_ROOT="${TELEO_STATE_ROOT:-/opt/teleo-eval/agent-state}"
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
_state_dir() {
|
||||
local agent="$1"
|
||||
echo "$STATE_ROOT/$agent"
|
||||
}
|
||||
|
||||
# --- Report (current status) ---
|
||||
|
||||
state_read_report() {
|
||||
local agent="$1"
|
||||
local file="$(_state_dir "$agent")/report.json"
|
||||
[ -f "$file" ] && cat "$file" || echo "{}"
|
||||
}
|
||||
|
||||
state_update_report() {
|
||||
local agent="$1"
|
||||
local status="$2"
|
||||
local summary="$3"
|
||||
local file="$(_state_dir "$agent")/report.json"
|
||||
|
||||
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_STATUS="$status" \
|
||||
_STATE_SUMMARY="$summary" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
python3 -c "
|
||||
import json, os
|
||||
try:
|
||||
with open(os.environ['_STATE_FILE']) as f:
|
||||
data = json.load(f)
|
||||
except:
|
||||
data = {'agent': os.environ['_STATE_AGENT']}
|
||||
data['status'] = os.environ['_STATE_STATUS']
|
||||
data['summary'] = os.environ['_STATE_SUMMARY']
|
||||
data['updated_at'] = os.environ['_STATE_TS']
|
||||
print(json.dumps(data, indent=2))
|
||||
" | _atomic_write_stdin "$file"
|
||||
}
|
||||
|
||||
# Variant that takes full JSON from stdin
|
||||
_atomic_write_stdin() {
|
||||
local filepath="$1"
|
||||
local tmpfile="${filepath}.tmp.$$"
|
||||
cat > "$tmpfile"
|
||||
mv -f "$tmpfile" "$filepath"
|
||||
}
|
||||
|
||||
# Full report update with session info (called at session end)
|
||||
state_finalize_report() {
|
||||
local agent="$1"
|
||||
local status="$2"
|
||||
local summary="$3"
|
||||
local session_id="$4"
|
||||
local started_at="$5"
|
||||
local ended_at="$6"
|
||||
local outcome="$7"
|
||||
local sources="$8"
|
||||
local branch="$9"
|
||||
local pr_number="${10}"
|
||||
local next_priority="${11:-null}"
|
||||
local file="$(_state_dir "$agent")/report.json"
|
||||
|
||||
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_STATUS="$status" \
|
||||
_STATE_SUMMARY="$summary" _STATE_SESSION_ID="$session_id" \
|
||||
_STATE_STARTED="$started_at" _STATE_ENDED="$ended_at" \
|
||||
_STATE_OUTCOME="$outcome" _STATE_SOURCES="$sources" \
|
||||
_STATE_BRANCH="$branch" _STATE_PR="$pr_number" \
|
||||
_STATE_NEXT="$next_priority" \
|
||||
python3 -c "
|
||||
import json, os
|
||||
e = os.environ
|
||||
sources = int(e['_STATE_SOURCES']) if e['_STATE_SOURCES'].isdigit() else 0
|
||||
pr = int(e['_STATE_PR']) if e['_STATE_PR'].isdigit() else None
|
||||
next_p = None if e['_STATE_NEXT'] == 'null' else e['_STATE_NEXT']
|
||||
data = {
|
||||
'agent': e['_STATE_AGENT'],
|
||||
'updated_at': e['_STATE_ENDED'],
|
||||
'status': e['_STATE_STATUS'],
|
||||
'summary': e['_STATE_SUMMARY'],
|
||||
'current_task': None,
|
||||
'last_session': {
|
||||
'id': e['_STATE_SESSION_ID'],
|
||||
'started_at': e['_STATE_STARTED'],
|
||||
'ended_at': e['_STATE_ENDED'],
|
||||
'outcome': e['_STATE_OUTCOME'],
|
||||
'sources_archived': sources,
|
||||
'branch': e['_STATE_BRANCH'],
|
||||
'pr_number': pr
|
||||
},
|
||||
'blocked_by': None,
|
||||
'next_priority': next_p
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
" | _atomic_write_stdin "$file"
|
||||
}
|
||||
|
||||
# --- Session ---
|
||||
|
||||
state_start_session() {
|
||||
local agent="$1"
|
||||
local session_id="$2"
|
||||
local type="$3"
|
||||
local domain="$4"
|
||||
local branch="$5"
|
||||
local model="${6:-sonnet}"
|
||||
local timeout="${7:-5400}"
|
||||
local started_at
|
||||
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
local file="$(_state_dir "$agent")/session.json"
|
||||
|
||||
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_SID="$session_id" \
|
||||
_STATE_STARTED="$started_at" _STATE_TYPE="$type" _STATE_DOMAIN="$domain" \
|
||||
_STATE_BRANCH="$branch" _STATE_MODEL="$model" _STATE_TIMEOUT="$timeout" \
|
||||
python3 -c "
|
||||
import json, os
|
||||
e = os.environ
|
||||
data = {
|
||||
'agent': e['_STATE_AGENT'],
|
||||
'session_id': e['_STATE_SID'],
|
||||
'started_at': e['_STATE_STARTED'],
|
||||
'ended_at': None,
|
||||
'type': e['_STATE_TYPE'],
|
||||
'domain': e['_STATE_DOMAIN'],
|
||||
'branch': e['_STATE_BRANCH'],
|
||||
'status': 'running',
|
||||
'model': e['_STATE_MODEL'],
|
||||
'timeout_seconds': int(e['_STATE_TIMEOUT']),
|
||||
'research_question': None,
|
||||
'belief_targeted': None,
|
||||
'disconfirmation_target': None,
|
||||
'sources_archived': 0,
|
||||
'sources_expected': 0,
|
||||
'tokens_used': None,
|
||||
'cost_usd': None,
|
||||
'errors': [],
|
||||
'handoff_notes': None
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
" | _atomic_write_stdin "$file"
|
||||
|
||||
echo "$started_at"
|
||||
}
|
||||
|
||||
state_end_session() {
|
||||
local agent="$1"
|
||||
local outcome="$2"
|
||||
local sources="${3:-0}"
|
||||
local pr_number="${4:-null}"
|
||||
local file="$(_state_dir "$agent")/session.json"
|
||||
|
||||
_STATE_FILE="$file" _STATE_OUTCOME="$outcome" _STATE_SOURCES="$sources" \
|
||||
_STATE_PR="$pr_number" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
python3 -c "
|
||||
import json, os
|
||||
e = os.environ
|
||||
with open(e['_STATE_FILE']) as f:
|
||||
data = json.load(f)
|
||||
data['ended_at'] = e['_STATE_TS']
|
||||
data['status'] = e['_STATE_OUTCOME']
|
||||
data['sources_archived'] = int(e['_STATE_SOURCES']) if e['_STATE_SOURCES'].isdigit() else 0
|
||||
pr = e.get('_STATE_PR', 'null')
|
||||
data['pr_number'] = int(pr) if pr.isdigit() else None
|
||||
print(json.dumps(data, indent=2))
|
||||
" | _atomic_write_stdin "$file"
|
||||
}
|
||||
|
||||
# --- Journal (append-only JSONL) ---
|
||||
|
||||
state_journal_append() {
|
||||
local agent="$1"
|
||||
local event="$2"
|
||||
shift 2
|
||||
# Remaining args are key=value pairs for extra fields
|
||||
local file="$(_state_dir "$agent")/journal.jsonl"
|
||||
|
||||
_STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" _STATE_EVT="$event" \
|
||||
python3 -c "
|
||||
import json, os, sys
|
||||
entry = {'ts': os.environ['_STATE_TS'], 'event': os.environ['_STATE_EVT']}
|
||||
for pair in sys.argv[1:]:
|
||||
k, _, v = pair.partition('=')
|
||||
if k:
|
||||
entry[k] = v
|
||||
print(json.dumps(entry))
|
||||
" "$@" >> "$file"
|
||||
}
|
||||
|
||||
# --- Metrics ---
|
||||
|
||||
state_update_metrics() {
|
||||
local agent="$1"
|
||||
local outcome="$2"
|
||||
local sources="${3:-0}"
|
||||
local file="$(_state_dir "$agent")/metrics.json"
|
||||
|
||||
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_OUTCOME="$outcome" \
|
||||
_STATE_SOURCES="$sources" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
python3 -c "
|
||||
import json, os
|
||||
e = os.environ
|
||||
try:
|
||||
with open(e['_STATE_FILE']) as f:
|
||||
data = json.load(f)
|
||||
except:
|
||||
data = {'agent': e['_STATE_AGENT'], 'lifetime': {}, 'rolling_30d': {}}
|
||||
|
||||
lt = data.setdefault('lifetime', {})
|
||||
lt['sessions_total'] = lt.get('sessions_total', 0) + 1
|
||||
outcome = e['_STATE_OUTCOME']
|
||||
if outcome == 'completed':
|
||||
lt['sessions_completed'] = lt.get('sessions_completed', 0) + 1
|
||||
elif outcome == 'timeout':
|
||||
lt['sessions_timeout'] = lt.get('sessions_timeout', 0) + 1
|
||||
elif outcome == 'error':
|
||||
lt['sessions_error'] = lt.get('sessions_error', 0) + 1
|
||||
lt['sources_archived'] = lt.get('sources_archived', 0) + (int(e['_STATE_SOURCES']) if e['_STATE_SOURCES'].isdigit() else 0)
|
||||
|
||||
data['updated_at'] = e['_STATE_TS']
|
||||
print(json.dumps(data, indent=2))
|
||||
" | _atomic_write_stdin "$file"
|
||||
}
|
||||
|
||||
# --- Inbox ---
|
||||
|
||||
state_check_inbox() {
|
||||
local agent="$1"
|
||||
local inbox="$(_state_dir "$agent")/inbox"
|
||||
[ -d "$inbox" ] && ls "$inbox"/*.json 2>/dev/null || true
|
||||
}
|
||||
|
||||
state_send_message() {
|
||||
local from="$1"
|
||||
local to="$2"
|
||||
local type="$3"
|
||||
local subject="$4"
|
||||
local body="$5"
|
||||
local inbox="$(_state_dir "$to")/inbox"
|
||||
local msg_id="msg-$(date +%s)-$$"
|
||||
local file="$inbox/${msg_id}.json"
|
||||
|
||||
mkdir -p "$inbox"
|
||||
_STATE_FILE="$file" _STATE_MSGID="$msg_id" _STATE_FROM="$from" \
|
||||
_STATE_TO="$to" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
_STATE_TYPE="$type" _STATE_SUBJECT="$subject" _STATE_BODY="$body" \
|
||||
python3 -c "
|
||||
import json, os
|
||||
e = os.environ
|
||||
data = {
|
||||
'id': e['_STATE_MSGID'],
|
||||
'from': e['_STATE_FROM'],
|
||||
'to': e['_STATE_TO'],
|
||||
'created_at': e['_STATE_TS'],
|
||||
'type': e['_STATE_TYPE'],
|
||||
'priority': 'normal',
|
||||
'subject': e['_STATE_SUBJECT'],
|
||||
'body': e['_STATE_BODY'],
|
||||
'source_ref': None,
|
||||
'expires_at': None
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
" | _atomic_write_stdin "$file"
|
||||
echo "$msg_id"
|
||||
}
|
||||
|
||||
# --- State directory check ---
|
||||
|
||||
state_ensure_dir() {
|
||||
local agent="$1"
|
||||
local dir="$(_state_dir "$agent")"
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "ERROR: Agent state not initialized for $agent. Run bootstrap.sh first." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Process cascade inbox messages after a research session.
|
||||
|
||||
For each unread cascade-*.md in an agent's inbox:
|
||||
1. Logs cascade_reviewed event to pipeline.db audit_log
|
||||
2. Moves the file to inbox/processed/
|
||||
|
||||
Usage: python3 process-cascade-inbox.py <agent-name>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_STATE_DIR = Path(os.environ.get("AGENT_STATE_DIR", "/opt/teleo-eval/agent-state"))
|
||||
PIPELINE_DB = Path(os.environ.get("PIPELINE_DB", "/opt/teleo-eval/pipeline/pipeline.db"))
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict:
|
||||
"""Parse YAML-like frontmatter from markdown."""
|
||||
fm = {}
|
||||
match = re.match(r'^---\n(.*?)\n---', text, re.DOTALL)
|
||||
if not match:
|
||||
return fm
|
||||
for line in match.group(1).strip().splitlines():
|
||||
if ':' in line:
|
||||
key, val = line.split(':', 1)
|
||||
fm[key.strip()] = val.strip().strip('"')
|
||||
return fm
|
||||
|
||||
|
||||
def process_agent_inbox(agent: str) -> int:
|
||||
"""Process cascade messages in agent's inbox. Returns count processed."""
|
||||
inbox_dir = AGENT_STATE_DIR / agent / "inbox"
|
||||
if not inbox_dir.exists():
|
||||
return 0
|
||||
|
||||
cascade_files = sorted(inbox_dir.glob("cascade-*.md"))
|
||||
if not cascade_files:
|
||||
return 0
|
||||
|
||||
# Ensure processed dir exists
|
||||
processed_dir = inbox_dir / "processed"
|
||||
processed_dir.mkdir(exist_ok=True)
|
||||
|
||||
processed = 0
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(PIPELINE_DB), timeout=10)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
except sqlite3.Error as e:
|
||||
print(f"WARNING: Cannot connect to pipeline.db: {e}", file=sys.stderr)
|
||||
# Still move files even if DB is unavailable
|
||||
conn = None
|
||||
|
||||
for cf in cascade_files:
|
||||
try:
|
||||
text = cf.read_text()
|
||||
fm = parse_frontmatter(text)
|
||||
|
||||
# Skip already-processed files
|
||||
if fm.get("status") == "processed":
|
||||
continue
|
||||
|
||||
# Log to audit_log
|
||||
if conn:
|
||||
detail = {
|
||||
"agent": agent,
|
||||
"cascade_file": cf.name,
|
||||
"subject": fm.get("subject", "unknown"),
|
||||
"original_created": fm.get("created", "unknown"),
|
||||
"reviewed_at": now,
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (stage, event, detail, timestamp) VALUES (?, ?, ?, ?)",
|
||||
("cascade", "cascade_reviewed", json.dumps(detail), now),
|
||||
)
|
||||
|
||||
# Move to processed
|
||||
dest = processed_dir / cf.name
|
||||
shutil.move(str(cf), str(dest))
|
||||
processed += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"WARNING: Failed to process {cf.name}: {e}", file=sys.stderr)
|
||||
|
||||
if conn:
|
||||
try:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <agent-name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agent = sys.argv[1]
|
||||
count = process_agent_inbox(agent)
|
||||
if count > 0:
|
||||
print(f"Processed {count} cascade message(s) for {agent}")
|
||||
# Exit 0 regardless — non-fatal
|
||||
sys.exit(0)
|
||||
|
|
@ -104,22 +104,14 @@ def main():
|
|||
claims_count = 0
|
||||
|
||||
if rel_path in existing:
|
||||
# Update status if different — but never regress from terminal states.
|
||||
# If DB says 'extracted' or 'null_result' and file happens to be in queue/
|
||||
# (e.g., failed archive push, zombie file), the DB is authoritative.
|
||||
# Downgrading to 'unprocessed' triggers the runaway re-extraction loop.
|
||||
# Update status if different
|
||||
current = conn.execute("SELECT status FROM sources WHERE path = ?", (rel_path,)).fetchone()
|
||||
TERMINAL_STATUSES = {"extracted", "null_result", "error", "ghost_no_file"}
|
||||
if current and current["status"] != status:
|
||||
if current["status"] in TERMINAL_STATUSES and status == "unprocessed":
|
||||
# Don't regress terminal → unprocessed. DB wins.
|
||||
pass
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE sources SET status = ?, updated_at = datetime('now') WHERE path = ?",
|
||||
(status, rel_path),
|
||||
)
|
||||
updated += 1
|
||||
conn.execute(
|
||||
"UPDATE sources SET status = ?, updated_at = datetime('now') WHERE path = ?",
|
||||
(status, rel_path),
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO sources (path, status, priority, claims_count, created_at, updated_at)
|
||||
290
batch-extract-50.sh
Executable file
290
batch-extract-50.sh
Executable file
|
|
@ -0,0 +1,290 @@
|
|||
#!/bin/bash
|
||||
# Batch extract sources from inbox/queue/ — v3 with two-gate skip logic
|
||||
#
|
||||
# Uses separate extract/ worktree (not main/ — prevents daemon race condition).
|
||||
# Skip logic uses two checks instead of local marker files (Ganymede v3 review):
|
||||
# Gate 1: Is source already in archive/{domain}/? → already processed, dedup
|
||||
# Gate 2: Does extraction branch exist on Forgejo? → extraction in progress
|
||||
# Gate 3: Does pipeline.db show ≥3 closed PRs for this source? → zombie, skip
|
||||
# All gates pass → extract
|
||||
#
|
||||
# Architecture: Ganymede (two-gate) + Rhea (separate worktrees)
|
||||
|
||||
REPO=/opt/teleo-eval/workspaces/extract
|
||||
MAIN_REPO=/opt/teleo-eval/workspaces/main
|
||||
EXTRACT=/opt/teleo-eval/openrouter-extract-v2.py
|
||||
CLEANUP=/opt/teleo-eval/post-extract-cleanup.py
|
||||
LOG=/opt/teleo-eval/logs/batch-extract-50.log
|
||||
DB=/opt/teleo-eval/pipeline/pipeline.db
|
||||
TOKEN=$(cat /opt/teleo-eval/secrets/forgejo-leo-token)
|
||||
FORGEJO_URL="http://localhost:3000"
|
||||
MAX=50
|
||||
MAX_CLOSED=3 # zombie retry limit: skip source after this many closed PRs
|
||||
COUNT=0
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# Lockfile to prevent concurrent runs
|
||||
LOCKFILE="/tmp/batch-extract.lock"
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
pid=$(cat "$LOCKFILE" 2>/dev/null)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[$(date)] SKIP: batch extract already running (pid $pid)" >> $LOG
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$LOCKFILE"
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
echo "[$(date)] Starting batch extraction of $MAX sources" >> $LOG
|
||||
|
||||
cd $REPO || exit 1
|
||||
|
||||
# Bug fix: don't swallow errors on critical git commands (Ganymede review)
|
||||
git fetch origin main >> $LOG 2>&1 || { echo "[$(date)] FATAL: fetch origin main failed" >> $LOG; exit 1; }
|
||||
git checkout -f main >> $LOG 2>&1 || { echo "[$(date)] FATAL: checkout main failed" >> $LOG; exit 1; }
|
||||
git reset --hard origin/main >> $LOG 2>&1 || { echo "[$(date)] FATAL: reset --hard failed" >> $LOG; exit 1; }
|
||||
|
||||
# SHA canary: verify extract worktree matches origin/main (Ganymede review)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse origin/main)
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "[$(date)] FATAL: extract worktree diverged from main ($LOCAL_SHA vs $REMOTE_SHA)" >> $LOG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pre-extraction cleanup: remove queue files that already exist in archive
|
||||
# This runs on the MAIN worktree (not extract/) so deletions are committed to git.
|
||||
# Prevents the "queue duplicate reappears after reset --hard" problem.
|
||||
CLEANED=0
|
||||
for qfile in $MAIN_REPO/inbox/queue/*.md; do
|
||||
[ -f "$qfile" ] || continue
|
||||
qbase=$(basename "$qfile")
|
||||
if find "$MAIN_REPO/inbox/archive" -name "$qbase" 2>/dev/null | grep -q .; then
|
||||
rm -f "$qfile"
|
||||
CLEANED=$((CLEANED + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$CLEANED" -gt 0 ]; then
|
||||
echo "[$(date)] Cleaned $CLEANED stale queue duplicates" >> $LOG
|
||||
cd $MAIN_REPO
|
||||
git add -A inbox/queue/ 2>/dev/null
|
||||
git commit -m "pipeline: clean $CLEANED stale queue duplicates
|
||||
|
||||
Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>" 2>/dev/null
|
||||
# Push with retry
|
||||
for attempt in 1 2 3; do
|
||||
git pull --rebase origin main 2>/dev/null
|
||||
git push origin main 2>/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
cd $REPO
|
||||
git fetch origin main 2>/dev/null
|
||||
git reset --hard origin/main 2>/dev/null
|
||||
fi
|
||||
|
||||
# Get sources in queue
|
||||
SOURCES=$(ls inbox/queue/*.md 2>/dev/null | head -$MAX)
|
||||
|
||||
# Batch fetch all remote branches once (Ganymede: 1 call instead of 84)
|
||||
REMOTE_BRANCHES=$(git ls-remote --heads origin 2>/dev/null)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[$(date)] ABORT: git ls-remote failed — remote unreachable, skipping cycle" >> $LOG
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for SOURCE in $SOURCES; do
|
||||
COUNT=$((COUNT + 1))
|
||||
BASENAME=$(basename "$SOURCE" .md)
|
||||
BRANCH="extract/$BASENAME"
|
||||
|
||||
# Skip already-processed sources (status set by extraction script, merged to main)
|
||||
if grep -q "^status: processed\|^status: enrichment\|^status: null-result" "$SOURCE" 2>/dev/null; then
|
||||
echo "[$(date)] [$COUNT/$MAX] SKIP $BASENAME (already processed — status in frontmatter)" >> $LOG
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip conversation archives — valuable content enters through standalone sources,
|
||||
# inline tags (SOURCE:/CLAIM:), and transcript review. Raw conversations produce
|
||||
# low-quality claims with schema failures. (Epimetheus session 4)
|
||||
if grep -q "^format: conversation" "$SOURCE" 2>/dev/null; then
|
||||
# Move to archive instead of leaving in queue (prevents re-processing)
|
||||
mv "$SOURCE" "$MAIN_REPO/inbox/archive/telegram/" 2>/dev/null
|
||||
echo "[$(date)] [$COUNT/$MAX] ARCHIVE $BASENAME (conversation — skipped extraction)" >> $LOG
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Gate 1: Already in archive? Source was already processed — dedup (Ganymede)
|
||||
if find "$MAIN_REPO/inbox/archive" -name "$BASENAME.md" 2>/dev/null | grep -q .; then
|
||||
echo "[$(date)] [$COUNT/$MAX] SKIP $BASENAME (already in archive)" >> $LOG
|
||||
# Delete the queue duplicate
|
||||
rm -f "$MAIN_REPO/inbox/queue/$BASENAME.md" 2>/dev/null
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Gate 2: Branch exists on Forgejo? Extraction already in progress (cached lookup)
|
||||
# Enhancement: 2-hour staleness check (Ganymede review) — if branch is >2h old
|
||||
# and PR is unmergeable, close PR + delete branch and re-extract
|
||||
if echo "$REMOTE_BRANCHES" | grep -q "refs/heads/$BRANCH$"; then
|
||||
# Check branch age
|
||||
BRANCH_SHA=$(echo "$REMOTE_BRANCHES" | grep "refs/heads/$BRANCH$" | awk '{print $1}')
|
||||
BRANCH_AGE_EPOCH=$(git log -1 --format='%ct' "$BRANCH_SHA" 2>/dev/null || echo 0)
|
||||
NOW_EPOCH=$(date +%s)
|
||||
AGE_HOURS=$(( (NOW_EPOCH - BRANCH_AGE_EPOCH) / 3600 ))
|
||||
|
||||
if [ "$AGE_HOURS" -ge 2 ]; then
|
||||
# Branch is stale — check if PR is mergeable
|
||||
# Note: Forgejo head= filter is unreliable. Fetch all open PRs and filter locally.
|
||||
PR_NUM=$(curl -sf "$FORGEJO_URL/api/v1/repos/teleo/teleo-codex/pulls?state=open&limit=50" \
|
||||
-H "Authorization: token $TOKEN" | python3 -c "
|
||||
import sys,json
|
||||
prs=json.load(sys.stdin)
|
||||
branch='$BRANCH'
|
||||
matches=[p for p in prs if p['head']['ref']==branch]
|
||||
print(matches[0]['number'] if matches else '')
|
||||
" 2>/dev/null)
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
PR_MERGEABLE=$(curl -sf "$FORGEJO_URL/api/v1/repos/teleo/teleo-codex/pulls/$PR_NUM" \
|
||||
-H "Authorization: token $TOKEN" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("mergeable","true"))' 2>/dev/null)
|
||||
if [ "$PR_MERGEABLE" = "False" ] || [ "$PR_MERGEABLE" = "false" ]; then
|
||||
echo "[$(date)] [$COUNT/$MAX] STALE: $BASENAME (${AGE_HOURS}h old, unmergeable PR #$PR_NUM) — closing + re-extracting" >> $LOG
|
||||
# Close PR with audit comment
|
||||
curl -sf -X POST "$FORGEJO_URL/api/v1/repos/teleo/teleo-codex/issues/$PR_NUM/comments" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"body":"Auto-closed: extraction branch stale >2h, conflict unresolvable. Source will be re-extracted from current main."}' > /dev/null 2>&1
|
||||
curl -sf -X PATCH "$FORGEJO_URL/api/v1/repos/teleo/teleo-codex/pulls/$PR_NUM" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' > /dev/null 2>&1
|
||||
# Delete remote branch
|
||||
git push origin --delete "$BRANCH" 2>/dev/null
|
||||
# Fall through to extraction below
|
||||
else
|
||||
echo "[$(date)] [$COUNT/$MAX] SKIP $BASENAME (branch exists ${AGE_HOURS}h, PR #$PR_NUM mergeable — waiting)" >> $LOG
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
else
|
||||
# No PR found but branch exists — orphan branch, clean up
|
||||
echo "[$(date)] [$COUNT/$MAX] STALE: $BASENAME (orphan branch ${AGE_HOURS}h, no PR) — deleting" >> $LOG
|
||||
git push origin --delete "$BRANCH" 2>/dev/null
|
||||
# Fall through to extraction
|
||||
fi
|
||||
else
|
||||
echo "[$(date)] [$COUNT/$MAX] SKIP $BASENAME (branch exists — in progress, ${AGE_HOURS}h old)" >> $LOG
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Gate 3: Check pipeline.db for zombie sources — too many closed PRs means
|
||||
# the source keeps failing eval. Skip after MAX_CLOSED rejections. (Epimetheus)
|
||||
if [ -f "$DB" ]; then
|
||||
CLOSED_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM prs WHERE branch = 'extract/$BASENAME' AND status = 'closed'" 2>/dev/null || echo 0)
|
||||
if [ "$CLOSED_COUNT" -ge "$MAX_CLOSED" ]; then
|
||||
echo "[$(date)] [$COUNT/$MAX] SKIP $BASENAME (zombie: $CLOSED_COUNT closed PRs >= $MAX_CLOSED limit)" >> $LOG
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date)] [$COUNT/$MAX] Processing $BASENAME" >> $LOG
|
||||
|
||||
# Reset to main (log errors — don't swallow)
|
||||
git checkout -f main >> $LOG 2>&1 || { echo " -> SKIP (checkout main failed)" >> $LOG; SKIPPED=$((SKIPPED + 1)); continue; }
|
||||
git fetch origin main >> $LOG 2>&1
|
||||
git reset --hard origin/main >> $LOG 2>&1 || { echo " -> SKIP (reset failed)" >> $LOG; SKIPPED=$((SKIPPED + 1)); continue; }
|
||||
|
||||
# Clean stale remote branch (Leo's catch — prevents checkout conflicts)
|
||||
git push origin --delete "$BRANCH" 2>/dev/null
|
||||
|
||||
# Create fresh branch
|
||||
git branch -D "$BRANCH" 2>/dev/null
|
||||
git checkout -b "$BRANCH" 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo " -> SKIP (branch creation failed)" >> $LOG
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Run extraction
|
||||
python3 $EXTRACT "$SOURCE" --no-review >> $LOG 2>&1
|
||||
EXTRACT_RC=$?
|
||||
|
||||
|
||||
|
||||
if [ $EXTRACT_RC -ne 0 ]; then
|
||||
FAILED=$((FAILED + 1))
|
||||
echo " -> FAILED (extract rc=$EXTRACT_RC)" >> $LOG
|
||||
continue
|
||||
fi
|
||||
|
||||
# Post-extraction cleanup
|
||||
python3 $CLEANUP $REPO >> $LOG 2>&1
|
||||
|
||||
# Check if any files were created/modified
|
||||
CHANGED=$(git status --porcelain | wc -l | tr -d " ")
|
||||
if [ "$CHANGED" -eq 0 ]; then
|
||||
echo " -> No changes (enrichment/null-result only)" >> $LOG
|
||||
continue
|
||||
fi
|
||||
|
||||
# Commit
|
||||
git add -A
|
||||
git commit -m "extract: $BASENAME
|
||||
|
||||
Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>" >> $LOG 2>&1
|
||||
|
||||
# Push
|
||||
git push "http://leo:${TOKEN}@localhost:3000/teleo/teleo-codex.git" "$BRANCH" --force >> $LOG 2>&1
|
||||
|
||||
# Create PR
|
||||
curl -sf -X POST "http://localhost:3000/api/v1/repos/teleo/teleo-codex/pulls" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"title\":\"extract: $BASENAME\",\"head\":\"$BRANCH\",\"base\":\"main\"}" >> /dev/null 2>&1
|
||||
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
echo " -> SUCCESS ($CHANGED files)" >> $LOG
|
||||
|
||||
# Move source from queue/ to archive/ on main worktree (prevents re-extraction)
|
||||
DOMAIN=$(grep -m1 "^domain:" "$MAIN_REPO/inbox/queue/$BASENAME.md" 2>/dev/null | sed 's/^domain:\s*//')
|
||||
ARCHIVE_DIR="$MAIN_REPO/inbox/archive/${DOMAIN:-uncategorized}"
|
||||
mkdir -p "$ARCHIVE_DIR"
|
||||
if [ -f "$MAIN_REPO/inbox/queue/$BASENAME.md" ]; then
|
||||
mv "$MAIN_REPO/inbox/queue/$BASENAME.md" "$ARCHIVE_DIR/$BASENAME.md"
|
||||
cd $MAIN_REPO
|
||||
git add "inbox/queue/$BASENAME.md" "inbox/archive/" 2>/dev/null
|
||||
git commit -m "pipeline: archive $BASENAME after extraction
|
||||
|
||||
Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>" >> $LOG 2>&1
|
||||
PUSH_OK=0
|
||||
for attempt in 1 2 3; do
|
||||
git pull --rebase origin main >> $LOG 2>&1
|
||||
git push origin main >> $LOG 2>&1 && { PUSH_OK=1; break; }
|
||||
sleep 1
|
||||
done
|
||||
if [ "$PUSH_OK" -eq 0 ]; then
|
||||
echo " -> WARNING: archive commit for $BASENAME failed to push after 3 attempts" >> $LOG
|
||||
fi
|
||||
cd $REPO
|
||||
git fetch origin main >> $LOG 2>&1
|
||||
git reset --hard origin/main >> $LOG 2>&1
|
||||
echo " -> Archived $BASENAME to $ARCHIVE_DIR" >> $LOG
|
||||
fi
|
||||
|
||||
# Back to main
|
||||
git checkout -f main >> $LOG 2>&1
|
||||
|
||||
# Rate limit
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[$(date)] Batch complete: $SUCCESS success, $FAILED failed, $SKIPPED skipped (already attempted)" >> $LOG
|
||||
|
||||
git checkout -f main >> $LOG 2>&1
|
||||
git reset --hard origin/main >> $LOG 2>&1
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
substitutions:
|
||||
_REGION: europe-west6
|
||||
_REPOSITORY: teleo
|
||||
_IMAGE: teleo-pipeline-gcp-staging
|
||||
_TAG: manual-local
|
||||
_REVISION: unknown
|
||||
|
||||
options:
|
||||
logging: CLOUD_LOGGING_ONLY
|
||||
machineType: E2_HIGHCPU_8
|
||||
|
||||
serviceAccount: projects/teleo-501523/serviceAccounts/sa-teleo-cloudbuild@teleo-501523.iam.gserviceaccount.com
|
||||
|
||||
steps:
|
||||
- id: build-staging-image
|
||||
name: gcr.io/cloud-builders/docker
|
||||
args:
|
||||
- build
|
||||
- -f
|
||||
- Dockerfile.gcp-staging
|
||||
- --label
|
||||
- org.opencontainers.image.source=https://github.com/living-ip/teleo-infrastructure
|
||||
- --label
|
||||
- org.opencontainers.image.revision=${_REVISION}
|
||||
- --label
|
||||
- livingip.revision=${_REVISION}
|
||||
- --label
|
||||
- livingip.surface=teleo-infrastructure
|
||||
- --label
|
||||
- livingip.tier=gcp-staging
|
||||
- -t
|
||||
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}
|
||||
- .
|
||||
|
||||
- id: smoke-test-image-before-push
|
||||
name: gcr.io/cloud-builders/docker
|
||||
args:
|
||||
- run
|
||||
- --rm
|
||||
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}
|
||||
|
||||
images:
|
||||
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
{
|
||||
"artifact": "teleo_gcp_service_communication_contract",
|
||||
"schema_version": 1,
|
||||
"project": "teleo-501523",
|
||||
"region": "europe-west6",
|
||||
"network": "teleo-staging-net",
|
||||
"global_invariants": {
|
||||
"no_public_database_ip": true,
|
||||
"no_broad_ssh_or_rdp": true,
|
||||
"no_default_compute_service_accounts": true,
|
||||
"secret_values_not_stored_in_contract": true,
|
||||
"database_connections_encrypted_only": true
|
||||
},
|
||||
"allowed_paths": [
|
||||
{
|
||||
"name": "github_actions_to_artifact_registry",
|
||||
"purpose": "Build, smoke-run, and push Teleo staging Docker image.",
|
||||
"source": {
|
||||
"type": "github_actions_wif",
|
||||
"repository": "living-ip/teleo-infrastructure"
|
||||
},
|
||||
"identity": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com",
|
||||
"target": {
|
||||
"type": "artifact_registry",
|
||||
"repository": "projects/teleo-501523/locations/europe-west6/repositories/teleo"
|
||||
},
|
||||
"protocol": "https",
|
||||
"ports": [443],
|
||||
"network_path": "google_apis",
|
||||
"encryption": "tls",
|
||||
"allowed_by": [".github/workflows/gcp-artifact.yml", "roles/artifactregistry.writer"],
|
||||
"forbidden": ["cloudsql.editor", "secretmanager.secretAccessor", "compute.admin"]
|
||||
},
|
||||
{
|
||||
"name": "github_actions_to_readiness_probe",
|
||||
"purpose": "Read-only GCP readiness probe with retained artifacts.",
|
||||
"source": {
|
||||
"type": "github_actions_wif",
|
||||
"repository": "living-ip/teleo-infrastructure"
|
||||
},
|
||||
"identity": "sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com",
|
||||
"target": {
|
||||
"type": "gcp_control_plane",
|
||||
"resources": ["artifact_registry", "compute_metadata", "cloudsql_metadata", "secret_metadata", "storage_metadata"]
|
||||
},
|
||||
"protocol": "https",
|
||||
"ports": [443],
|
||||
"network_path": "google_apis",
|
||||
"encryption": "tls",
|
||||
"allowed_by": [".github/workflows/gcp-readiness.yml", "ops/plan_gcp_iam_split.py"],
|
||||
"forbidden": ["artifactregistry.writer", "cloudsql.editor", "secretmanager.secretAccessor"]
|
||||
},
|
||||
{
|
||||
"name": "operator_ssh_to_teleo_vms",
|
||||
"purpose": "Emergency/operator shell access only from one retained operator IPv4 /32.",
|
||||
"source": {
|
||||
"type": "operator_ip",
|
||||
"cidrs": ["<operator-ip>/32"]
|
||||
},
|
||||
"identity": "oslogin_operator",
|
||||
"target": {
|
||||
"type": "compute_instance_tags",
|
||||
"tags": ["teleo-prod-ssh", "teleo-staging-ssh"]
|
||||
},
|
||||
"protocol": "tcp",
|
||||
"ports": [22],
|
||||
"network_path": "vpc_firewall",
|
||||
"encryption": "ssh",
|
||||
"allowed_by": ["ops/apply_gcp_runtime_baseline.py", "ops/check_gcp_infra_readiness.py"],
|
||||
"forbidden": ["broad_ipv4_ingress", "broad_ipv6_ingress", "tcp:3389"]
|
||||
},
|
||||
{
|
||||
"name": "teleo_vms_to_artifact_registry",
|
||||
"purpose": "Runtime pulls immutable images from Artifact Registry over Private Google Access.",
|
||||
"source": {
|
||||
"type": "compute_service_accounts",
|
||||
"service_accounts": [
|
||||
"sa-teleo-prod-vm@teleo-501523.iam.gserviceaccount.com",
|
||||
"sa-teleo-staging-vm@teleo-501523.iam.gserviceaccount.com"
|
||||
]
|
||||
},
|
||||
"identity": "dedicated_vm_service_accounts",
|
||||
"target": {
|
||||
"type": "artifact_registry",
|
||||
"repository": "projects/teleo-501523/locations/europe-west6/repositories/teleo"
|
||||
},
|
||||
"protocol": "https",
|
||||
"ports": [443],
|
||||
"network_path": "private_google_access",
|
||||
"encryption": "tls",
|
||||
"allowed_by": ["roles/artifactregistry.reader", "ops/apply_gcp_runtime_baseline.py"],
|
||||
"forbidden": ["default_compute_service_account", "public_image_registry_required"]
|
||||
},
|
||||
{
|
||||
"name": "teleo_vms_to_cloudsql_private",
|
||||
"purpose": "Application and restore readback connections to the private Cloud SQL standby.",
|
||||
"source": {
|
||||
"type": "compute_service_accounts",
|
||||
"service_accounts": [
|
||||
"sa-teleo-prod-vm@teleo-501523.iam.gserviceaccount.com",
|
||||
"sa-teleo-staging-vm@teleo-501523.iam.gserviceaccount.com"
|
||||
]
|
||||
},
|
||||
"identity": "dedicated_vm_service_accounts",
|
||||
"target": {
|
||||
"type": "cloudsql_postgres",
|
||||
"instance": "teleo-pgvector-standby",
|
||||
"database": "teleo_kb",
|
||||
"private_network": "projects/teleo-501523/global/networks/teleo-staging-net",
|
||||
"public_ip": false
|
||||
},
|
||||
"protocol": "postgres",
|
||||
"ports": [5432],
|
||||
"network_path": "private_vpc",
|
||||
"encryption": "encrypted_only",
|
||||
"allowed_by": ["ops/apply_gcp_runtime_baseline.py", "ops/check_gcp_infra_readiness.py"],
|
||||
"forbidden": ["public_ip", "ssl_disabled", "authorized_networks_public"]
|
||||
},
|
||||
{
|
||||
"name": "cloudsql_import_from_backup_bucket",
|
||||
"purpose": "Cloud SQL import operation reads generated restore SQL from the versioned backup bucket.",
|
||||
"source": {
|
||||
"type": "cloudsql_instance_service_account",
|
||||
"discovered_by": "gcloud sql instances describe teleo-pgvector-standby --format=value(serviceAccountEmailAddress)"
|
||||
},
|
||||
"identity": "cloudsql_instance_service_account",
|
||||
"target": {
|
||||
"type": "gcs_bucket",
|
||||
"bucket": "gs://teleo-501523-prod-backups",
|
||||
"prefix": "kb-dumps/cloudsql-restore-drills/"
|
||||
},
|
||||
"protocol": "https",
|
||||
"ports": [443],
|
||||
"network_path": "google_apis",
|
||||
"encryption": "tls",
|
||||
"allowed_by": ["roles/storage.objectAdmin", "ops/apply_gcp_iam_split.py"],
|
||||
"forbidden": ["allUsers", "allAuthenticatedUsers", "publicAccessPreventionDisabled"]
|
||||
},
|
||||
{
|
||||
"name": "restore_drill_operator_to_cloudsql_admin",
|
||||
"purpose": "Operator-triggered Cloud SQL import drill and post-import readback.",
|
||||
"source": {
|
||||
"type": "operator_or_ci_with_explicit_auth",
|
||||
"requires_retained_execute_proof": true
|
||||
},
|
||||
"identity": "sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com",
|
||||
"target": {
|
||||
"type": "cloudsql_admin_api",
|
||||
"instance": "teleo-pgvector-standby"
|
||||
},
|
||||
"protocol": "https",
|
||||
"ports": [443],
|
||||
"network_path": "google_apis",
|
||||
"encryption": "tls",
|
||||
"allowed_by": ["roles/cloudsql.editor", "ops/run_gcp_cloudsql_restore_drill.sh"],
|
||||
"forbidden": ["unretained_execute", "raw_password_in_logs"]
|
||||
},
|
||||
{
|
||||
"name": "vps_backup_to_gcs_bucket",
|
||||
"purpose": "Source backup artifacts are uploaded to versioned GCS only through an approved backup identity.",
|
||||
"source": {
|
||||
"type": "hetzner_vps_or_operator_export",
|
||||
"host": "teleo@77.42.65.182"
|
||||
},
|
||||
"identity": "approved_backup_writer_or_operator_session",
|
||||
"target": {
|
||||
"type": "gcs_bucket",
|
||||
"bucket": "gs://teleo-501523-prod-backups",
|
||||
"prefix": "kb-dumps/"
|
||||
},
|
||||
"protocol": "https",
|
||||
"ports": [443],
|
||||
"network_path": "google_apis",
|
||||
"encryption": "tls",
|
||||
"allowed_by": ["ops/backup_vps_sqlite_kb.sh", "ops/run_gcp_cloudsql_restore_drill.sh"],
|
||||
"forbidden": ["raw_private_key_in_contract", "database_public_ingress", "unversioned_bucket"]
|
||||
}
|
||||
],
|
||||
"not_proven_by_this_contract": [
|
||||
"that GCP resources currently exist",
|
||||
"that firewall rules are currently applied",
|
||||
"that Cloud SQL import/readback has succeeded",
|
||||
"that production Telegram/Leo traffic has cut over to GCP"
|
||||
]
|
||||
}
|
||||
56
deploy.sh
Executable file
56
deploy.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
# Deploy teleo-pipeline to VPS.
|
||||
# Usage: ./deploy.sh [--restart]
|
||||
#
|
||||
# Pulls latest from current branch, updates venv, optionally restarts service.
|
||||
# Run from the VPS as the teleo user, or via SSH:
|
||||
# ssh teleo@77.42.65.182 'cd /opt/teleo-eval/pipeline && ./deploy.sh --restart'
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_DIR="/opt/teleo-eval/pipeline"
|
||||
VENV_DIR="${DEPLOY_DIR}/.venv"
|
||||
SERVICE="teleo-pipeline"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "=== Pulling latest ==="
|
||||
git pull --ff-only
|
||||
|
||||
echo "=== Updating venv ==="
|
||||
"${VENV_DIR}/bin/pip" install -q -e ".[dev]" 2>/dev/null || \
|
||||
"${VENV_DIR}/bin/pip" install -q -e .
|
||||
|
||||
echo "=== Syntax check ==="
|
||||
"${VENV_DIR}/bin/python3" -c "
|
||||
import ast, pathlib, sys
|
||||
errors = []
|
||||
for f in pathlib.Path('.').rglob('*.py'):
|
||||
if '.venv' in str(f):
|
||||
continue
|
||||
try:
|
||||
ast.parse(f.read_text())
|
||||
except SyntaxError as e:
|
||||
errors.append(f'{f}: {e}')
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f'SYNTAX ERROR: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print('All Python files pass syntax check')
|
||||
"
|
||||
|
||||
if [[ "${1:-}" == "--restart" ]]; then
|
||||
echo "=== Restarting ${SERVICE} ==="
|
||||
sudo systemctl restart "$SERVICE"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE"; then
|
||||
echo "=== ${SERVICE} is running ==="
|
||||
systemctl status "$SERVICE" --no-pager -l | head -15
|
||||
else
|
||||
echo "ERROR: ${SERVICE} failed to start" >&2
|
||||
journalctl -u "$SERVICE" --no-pager -n 20
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "=== Deploy complete (service not restarted — use --restart to restart) ==="
|
||||
fi
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# auto-deploy.sh — Pull from Forgejo, sync to working dirs, restart if needed.
|
||||
# Runs as systemd timer (teleo-auto-deploy.timer) every 2 minutes.
|
||||
# Exits silently when nothing has changed.
|
||||
set -euo pipefail
|
||||
|
||||
LOCK_FILE="/tmp/teleo-auto-deploy.lock"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
logger -t "auto-deploy" "Another deploy is already running. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DEPLOY_CHECKOUT="/opt/teleo-eval/workspaces/deploy-infra"
|
||||
PIPELINE_DIR="/opt/teleo-eval/pipeline"
|
||||
TELEGRAM_DIR="/opt/teleo-eval/telegram"
|
||||
DIAGNOSTICS_DIR="/opt/teleo-eval/diagnostics"
|
||||
AGENT_STATE_DIR="/opt/teleo-eval/ops/agent-state"
|
||||
LEOCLEAN_BIN_DIR="/home/teleo/.hermes/profiles/leoclean/bin"
|
||||
LEOCLEAN_SKILLS_DIR="/home/teleo/.hermes/profiles/leoclean/skills"
|
||||
LEOCLEAN_PLUGINS_DIR="/home/teleo/.hermes/profiles/leoclean/plugins"
|
||||
LEOCLEAN_CONFIG="/home/teleo/.hermes/profiles/leoclean/config.yaml"
|
||||
HERMES_AGENT_DIR="/home/teleo/.hermes/hermes-agent"
|
||||
HERMES_PATCH_DIR="/home/teleo/.hermes/teleo-runtime-patches"
|
||||
LEOCLEAN_POLICY_PATCH="$HERMES_PATCH_DIR/enforce_leoclean_learning_policy.py"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
STAMP_FILE="/opt/teleo-eval/.last-deploy-sha"
|
||||
LOG_TAG="auto-deploy"
|
||||
|
||||
log() { logger -t "$LOG_TAG" "$1"; echo "$(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
|
||||
DEPLOY_REMOTE="${TELEO_DEPLOY_REMOTE:-}"
|
||||
if [ -z "$DEPLOY_REMOTE" ]; then
|
||||
if git -C "$DEPLOY_CHECKOUT" remote get-url github >/dev/null 2>&1; then
|
||||
DEPLOY_REMOTE="github"
|
||||
else
|
||||
DEPLOY_REMOTE="origin"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -d "$DEPLOY_CHECKOUT/.git" ]; then
|
||||
log "ERROR: Deploy checkout not found at $DEPLOY_CHECKOUT. Run setup first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_CHECKOUT"
|
||||
if ! git remote get-url "$DEPLOY_REMOTE" >/dev/null 2>&1; then
|
||||
log "ERROR: deploy remote '$DEPLOY_REMOTE' is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if ! git fetch "$DEPLOY_REMOTE" main --quiet 2>&1; then
|
||||
log "ERROR: git fetch failed for $DEPLOY_REMOTE/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_SHA=$(git rev-parse "$DEPLOY_REMOTE/main")
|
||||
OLD_SHA=$(cat "$STAMP_FILE" 2>/dev/null || echo "none")
|
||||
|
||||
if [ "$NEW_SHA" = "$OLD_SHA" ]; then
|
||||
LEOCLEAN_DRIFT_REPAIRED=false
|
||||
# Hermes can be upgraded independently of this repository. Repair an
|
||||
# overwritten response-transform hook even when no infrastructure commit
|
||||
# changed, and restart only when the patch was actually reapplied.
|
||||
if [ -f "$HERMES_PATCH_DIR/apply_response_transform_hook.py" ] && [ -f "$HERMES_AGENT_DIR/run_agent.py" ]; then
|
||||
if ! PATCH_RESULT=$(python3 "$HERMES_PATCH_DIR/apply_response_transform_hook.py" "$HERMES_AGENT_DIR/run_agent.py"); then
|
||||
log "ERROR: Hermes response-transform drift could not be repaired"
|
||||
exit 1
|
||||
fi
|
||||
if echo "$PATCH_RESULT" | grep -q '"status": "installed_now"'; then
|
||||
log "Hermes response-transform drift repaired: $PATCH_RESULT"
|
||||
LEOCLEAN_DRIFT_REPAIRED=true
|
||||
fi
|
||||
fi
|
||||
if [ -f "$LEOCLEAN_POLICY_PATCH" ] && [ -f "$LEOCLEAN_CONFIG" ]; then
|
||||
if ! POLICY_RESULT=$(python3 "$LEOCLEAN_POLICY_PATCH" "$LEOCLEAN_CONFIG"); then
|
||||
log "ERROR: leoclean learning-policy drift could not be repaired"
|
||||
exit 1
|
||||
fi
|
||||
if echo "$POLICY_RESULT" | grep -q '"status": "updated"'; then
|
||||
log "Leoclean learning-policy drift repaired: $POLICY_RESULT"
|
||||
LEOCLEAN_DRIFT_REPAIRED=true
|
||||
fi
|
||||
fi
|
||||
if $LEOCLEAN_DRIFT_REPAIRED && systemctl is-active --quiet leoclean-gateway.service; then
|
||||
sudo systemctl restart leoclean-gateway
|
||||
sleep 10
|
||||
if ! systemctl is-active --quiet leoclean-gateway.service; then
|
||||
log "ERROR: leoclean-gateway failed after runtime-policy drift repair"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "New commits: ${OLD_SHA:0:8} -> ${NEW_SHA:0:8}"
|
||||
|
||||
if ! git checkout main --quiet 2>&1; then
|
||||
log "ERROR: git checkout main failed — dirty tree or corrupted index"
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge --ff-only "$DEPLOY_REMOTE/main" --quiet 2>&1; then
|
||||
log "ERROR: git merge --ff-only $DEPLOY_REMOTE/main failed. Manual intervention needed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A running shell keeps the old script body after git updates this file. Re-exec
|
||||
# once from the merged checkout so newly added sync/restart paths take effect in
|
||||
# the same deploy instead of writing a false-success stamp.
|
||||
if [ "${TELEO_AUTO_DEPLOY_REEXECED:-0}" != "1" ] \
|
||||
&& [ "$OLD_SHA" != "none" ] \
|
||||
&& git diff --name-only "$OLD_SHA" "$NEW_SHA" -- deploy/auto-deploy.sh deploy/leoclean-gateway-restart-required.sh \
|
||||
| grep -q .; then
|
||||
log "Deploy logic changed; re-executing merged deploy script"
|
||||
export TELEO_AUTO_DEPLOY_REEXECED=1
|
||||
exec bash "$DEPLOY_CHECKOUT/deploy/auto-deploy.sh"
|
||||
fi
|
||||
|
||||
# Syntax check all Python files before copying
|
||||
ERRORS=0
|
||||
for f in lib/*.py *.py diagnostics/*.py telegram/*.py tests/*.py hermes-agent/leoclean-bin/*.py hermes-agent/leoclean-plugins/vps/*/*.py hermes-agent/patches/*.py scripts/compile_kb_source_packet.py scripts/kb_proposal_normalize.py scripts/prepare_kb_source_manifest.py scripts/leo_behavior_manifest.py scripts/leo_turn_execution_manifest.py; do
|
||||
[ -f "$f" ] || continue
|
||||
if ! python3 -c "import ast, sys; ast.parse(open(sys.argv[1]).read())" "$f" 2>&1; then
|
||||
log "SYNTAX ERROR: $f"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
log "ERROR: $ERRORS syntax errors. Deploy aborted. Fix and push again."
|
||||
exit 1
|
||||
fi
|
||||
log "Syntax check passed"
|
||||
|
||||
# Sync to working directories
|
||||
RSYNC_OPTS=(-az --exclude __pycache__ --exclude '*.pyc' --exclude '*.bak*')
|
||||
|
||||
rsync "${RSYNC_OPTS[@]}" lib/ "$PIPELINE_DIR/lib/"
|
||||
|
||||
for f in teleo-pipeline.py reweave.py fetch_coins.py pipeline-health-check.py; do
|
||||
[ -f "$f" ] && rsync "${RSYNC_OPTS[@]}" "$f" "$PIPELINE_DIR/$f"
|
||||
done
|
||||
|
||||
rsync "${RSYNC_OPTS[@]}" telegram/ "$PIPELINE_DIR/telegram/"
|
||||
rsync "${RSYNC_OPTS[@]}" telegram/ "$TELEGRAM_DIR/"
|
||||
rsync "${RSYNC_OPTS[@]}" diagnostics/ "$DIAGNOSTICS_DIR/"
|
||||
rsync "${RSYNC_OPTS[@]}" agent-state/ "$AGENT_STATE_DIR/"
|
||||
rsync "${RSYNC_OPTS[@]}" tests/ "$PIPELINE_DIR/tests/"
|
||||
if [ -d hermes-agent/leoclean-bin ]; then
|
||||
mkdir -p "$LEOCLEAN_BIN_DIR"
|
||||
rsync "${RSYNC_OPTS[@]}" hermes-agent/leoclean-bin/ "$LEOCLEAN_BIN_DIR/"
|
||||
fi
|
||||
if [ -d hermes-agent/leoclean-skills/vps ]; then
|
||||
mkdir -p "$LEOCLEAN_SKILLS_DIR"
|
||||
rsync "${RSYNC_OPTS[@]}" hermes-agent/leoclean-skills/vps/ "$LEOCLEAN_SKILLS_DIR/"
|
||||
fi
|
||||
if [ -d hermes-agent/leoclean-plugins/vps ]; then
|
||||
mkdir -p "$LEOCLEAN_PLUGINS_DIR"
|
||||
rsync "${RSYNC_OPTS[@]}" hermes-agent/leoclean-plugins/vps/ "$LEOCLEAN_PLUGINS_DIR/"
|
||||
fi
|
||||
if [ -d hermes-agent/patches ]; then
|
||||
mkdir -p "$HERMES_PATCH_DIR"
|
||||
rsync "${RSYNC_OPTS[@]}" hermes-agent/patches/ "$HERMES_PATCH_DIR/"
|
||||
fi
|
||||
[ -f research/research-session.sh ] && rsync "${RSYNC_OPTS[@]}" research/research-session.sh /opt/teleo-eval/research-session.sh
|
||||
|
||||
if [ -f "$HERMES_PATCH_DIR/apply_response_transform_hook.py" ]; then
|
||||
PATCH_RESULT=$(python3 "$HERMES_PATCH_DIR/apply_response_transform_hook.py" "$HERMES_AGENT_DIR/run_agent.py")
|
||||
log "Hermes response transform: $PATCH_RESULT"
|
||||
fi
|
||||
LEOCLEAN_POLICY_CHANGED=false
|
||||
if [ -f "$LEOCLEAN_POLICY_PATCH" ] && [ -f "$LEOCLEAN_CONFIG" ]; then
|
||||
POLICY_RESULT=$(python3 "$LEOCLEAN_POLICY_PATCH" "$LEOCLEAN_CONFIG")
|
||||
log "Leoclean learning policy: $POLICY_RESULT"
|
||||
if echo "$POLICY_RESULT" | grep -q '"status": "updated"'; then
|
||||
LEOCLEAN_POLICY_CHANGED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Safety net: ensure synced .sh files are executable after rsync.
|
||||
# Keep this bounded to deploy-owned paths: /opt/teleo-eval also contains
|
||||
# backups and generated state that may be unreadable by the deploy user.
|
||||
for dir in "$PIPELINE_DIR" "$TELEGRAM_DIR" "$DIAGNOSTICS_DIR" "$AGENT_STATE_DIR" "$LEOCLEAN_BIN_DIR" "$LEOCLEAN_SKILLS_DIR" "$LEOCLEAN_PLUGINS_DIR" "$HERMES_PATCH_DIR"; do
|
||||
[ -d "$dir" ] || continue
|
||||
find "$dir" -maxdepth 3 -name '*.sh' -not -perm -u+x -exec chmod +x {} +
|
||||
done
|
||||
[ -f /opt/teleo-eval/research-session.sh ] && chmod u+x /opt/teleo-eval/research-session.sh
|
||||
|
||||
log "Files synced"
|
||||
|
||||
if [ "$OLD_SHA" = "none" ] || git diff --name-only "$OLD_SHA" "$NEW_SHA" -- systemd/ 2>/dev/null | grep -q .; then
|
||||
SYSTEMD_CHANGED=false
|
||||
for unit in systemd/*.service systemd/*.timer; do
|
||||
[ -f "$unit" ] || continue
|
||||
installed_unit="$SYSTEMD_DIR/$(basename "$unit")"
|
||||
if [ -f "$installed_unit" ] && cmp -s "$unit" "$installed_unit"; then
|
||||
continue
|
||||
fi
|
||||
SYSTEMD_CHANGED=true
|
||||
log "Installing changed systemd unit: $(basename "$unit")"
|
||||
sudo install -m 0644 "$unit" "$SYSTEMD_DIR/$(basename "$unit")"
|
||||
done
|
||||
if $SYSTEMD_CHANGED; then
|
||||
sudo systemctl daemon-reload
|
||||
if [ -f systemd/teleo-auto-deploy.timer ]; then
|
||||
sudo systemctl enable --now teleo-auto-deploy.timer >/dev/null
|
||||
fi
|
||||
if [ -f systemd/teleo-agent-healthcheck.timer ]; then
|
||||
sudo systemctl enable --now teleo-agent-healthcheck.timer >/dev/null
|
||||
fi
|
||||
else
|
||||
log "Systemd units already match the repository; skipping privileged installation"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart services only when changed files affect a running process.
|
||||
RESTART=""
|
||||
add_restart() {
|
||||
case " $RESTART " in
|
||||
*" $1 "*) ;;
|
||||
*) RESTART="$RESTART $1" ;;
|
||||
esac
|
||||
}
|
||||
add_restart_if_unit_exists() {
|
||||
if systemctl list-units --all --full "$1.service" --no-legend 2>/dev/null | grep -q .; then
|
||||
add_restart "$1"
|
||||
fi
|
||||
}
|
||||
add_restart_if_unit_active() {
|
||||
if systemctl is-active --quiet "$1.service"; then
|
||||
add_restart "$1"
|
||||
fi
|
||||
}
|
||||
if [ "$OLD_SHA" != "none" ]; then
|
||||
if git diff --name-only "$OLD_SHA" "$NEW_SHA" -- lib/ teleo-pipeline.py reweave.py telegram/ 2>/dev/null | grep -q '\.py$'; then
|
||||
add_restart teleo-pipeline
|
||||
fi
|
||||
if git diff --name-only "$OLD_SHA" "$NEW_SHA" -- telegram/ 2>/dev/null | grep -q '\.py$'; then
|
||||
add_restart_if_unit_active teleo-agent@leo
|
||||
add_restart_if_unit_exists teleo-agent@leo-wallet-test
|
||||
fi
|
||||
if git diff --name-only "$OLD_SHA" "$NEW_SHA" -- systemd/teleo-agent@.service 2>/dev/null | grep -q .; then
|
||||
add_restart_if_unit_active teleo-agent@leo
|
||||
add_restart_if_unit_exists teleo-agent@leo-wallet-test
|
||||
fi
|
||||
if git diff --name-only "$OLD_SHA" "$NEW_SHA" -- diagnostics/ 2>/dev/null | grep -q '\.py$'; then
|
||||
add_restart teleo-diagnostics
|
||||
fi
|
||||
if git diff --name-only "$OLD_SHA" "$NEW_SHA" -- hermes-agent/leoclean-bin/ hermes-agent/leoclean-skills/vps/ hermes-agent/leoclean-plugins/vps/ hermes-agent/patches/ 2>/dev/null \
|
||||
| bash deploy/leoclean-gateway-restart-required.sh; then
|
||||
add_restart_if_unit_active leoclean-gateway
|
||||
fi
|
||||
if $LEOCLEAN_POLICY_CHANGED; then
|
||||
add_restart_if_unit_active leoclean-gateway
|
||||
fi
|
||||
else
|
||||
RESTART="teleo-pipeline teleo-diagnostics"
|
||||
add_restart_if_unit_active teleo-agent@leo
|
||||
add_restart_if_unit_exists teleo-agent@leo-wallet-test
|
||||
add_restart_if_unit_active leoclean-gateway
|
||||
fi
|
||||
|
||||
if [ -n "$RESTART" ]; then
|
||||
log "Restarting:$RESTART"
|
||||
for svc in $RESTART; do
|
||||
sudo systemctl restart "$svc"
|
||||
done
|
||||
sleep 30
|
||||
|
||||
FAIL=0
|
||||
for svc in $RESTART; do
|
||||
if systemctl is-active --quiet "$svc"; then
|
||||
log "$svc: active"
|
||||
else
|
||||
log "ERROR: $svc failed to start"
|
||||
journalctl -u "$svc" -n 5 --no-pager 2>/dev/null || true
|
||||
FAIL=1
|
||||
fi
|
||||
if [ "$svc" = "leoclean-gateway" ] && [ -f "$HERMES_PATCH_DIR/apply_response_transform_hook.py" ]; then
|
||||
if PATCH_CHECK=$(python3 "$HERMES_PATCH_DIR/apply_response_transform_hook.py" --check "$HERMES_AGENT_DIR/run_agent.py"); then
|
||||
log "leoclean-gateway response transform: $PATCH_CHECK"
|
||||
else
|
||||
log "ERROR: leoclean-gateway response transform check failed: $PATCH_CHECK"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
if [ "$svc" = "leoclean-gateway" ] && [ -f "$LEOCLEAN_POLICY_PATCH" ]; then
|
||||
if POLICY_CHECK=$(python3 "$LEOCLEAN_POLICY_PATCH" --check "$LEOCLEAN_CONFIG"); then
|
||||
log "leoclean-gateway learning policy: $POLICY_CHECK"
|
||||
else
|
||||
log "ERROR: leoclean-gateway learning policy check failed: $POLICY_CHECK"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if echo "$RESTART" | grep -q "teleo-pipeline"; then
|
||||
HEALTH_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 3 http://localhost:8080/health 2>/dev/null || echo "000")
|
||||
if [ "$HEALTH_CODE" = "200" ] || [ "$HEALTH_CODE" = "503" ]; then
|
||||
log "pipeline health: OK (HTTP $HEALTH_CODE)"
|
||||
else
|
||||
log "WARNING: pipeline health check failed (HTTP $HEALTH_CODE)"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if echo "$RESTART" | grep -q "teleo-diagnostics"; then
|
||||
if curl -sf --connect-timeout 3 http://localhost:8081/ops > /dev/null 2>&1; then
|
||||
log "diagnostics health: OK"
|
||||
else
|
||||
log "WARNING: diagnostics health check failed"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
log "WARNING: Smoke test failures. NOT updating stamp. Will retry next cycle. Push a fix."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "No runtime changes - services not restarted"
|
||||
fi
|
||||
|
||||
echo "$NEW_SHA" > "$STAMP_FILE"
|
||||
log "Deploy complete: $(git log --oneline -1 "$NEW_SHA")"
|
||||
158
deploy/deploy.sh
158
deploy/deploy.sh
|
|
@ -1,158 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# deploy.sh — Deploy pipeline and diagnostics to VPS from repo
|
||||
# Usage: ./deploy.sh [--dry-run] [--restart]
|
||||
#
|
||||
# Requires: committed, clean working tree. Enforces repo-first workflow.
|
||||
set -euo pipefail
|
||||
|
||||
VPS_HOST="teleo@77.42.65.182"
|
||||
VPS_PIPELINE="/opt/teleo-eval/pipeline"
|
||||
VPS_TELEGRAM="/opt/teleo-eval/telegram"
|
||||
VPS_DIAGNOSTICS="/opt/teleo-eval/diagnostics"
|
||||
VPS_AGENT_STATE="/opt/teleo-eval/ops/agent-state"
|
||||
VPS_LEOCLEAN_BIN="/home/teleo/.hermes/profiles/leoclean/bin"
|
||||
VPS_LEOCLEAN_SKILLS="/home/teleo/.hermes/profiles/leoclean/skills"
|
||||
VPS_LEOCLEAN_PLUGINS="/home/teleo/.hermes/profiles/leoclean/plugins"
|
||||
VPS_LEOCLEAN_CONFIG="/home/teleo/.hermes/profiles/leoclean/config.yaml"
|
||||
VPS_HERMES_AGENT="/home/teleo/.hermes/hermes-agent"
|
||||
VPS_HERMES_PATCHES="/home/teleo/.hermes/teleo-runtime-patches"
|
||||
VPS_LEOCLEAN_POLICY_PATCH="$VPS_HERMES_PATCHES/enforce_leoclean_learning_policy.py"
|
||||
VPS_SYSTEMD="/etc/systemd/system"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
DRY_RUN=false
|
||||
RESTART=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--restart) RESTART=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--dry-run] [--restart]"
|
||||
echo " --dry-run Show what would be deployed without doing it"
|
||||
echo " --restart Restart services after deploy"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown arg: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Gate: working tree must be clean
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
|
||||
echo "ERROR: Uncommitted changes. Commit first, deploy second."
|
||||
git -C "$REPO_ROOT" status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploying from commit: $(git -C "$REPO_ROOT" log --oneline -1)"
|
||||
echo ""
|
||||
|
||||
# Syntax check all Python files before deploying
|
||||
echo "=== Pre-deploy syntax check ==="
|
||||
ERRORS=0
|
||||
for f in "$REPO_ROOT/lib/"*.py "$REPO_ROOT/"*.py "$REPO_ROOT/diagnostics/"*.py "$REPO_ROOT/telegram/"*.py "$REPO_ROOT/hermes-agent/leoclean-bin/"*.py "$REPO_ROOT/hermes-agent/leoclean-plugins/vps/"*/*.py "$REPO_ROOT/hermes-agent/patches/"*.py "$REPO_ROOT/scripts/compile_kb_source_packet.py" "$REPO_ROOT/scripts/kb_proposal_normalize.py" "$REPO_ROOT/scripts/prepare_kb_source_manifest.py" "$REPO_ROOT/scripts/leo_behavior_manifest.py" "$REPO_ROOT/scripts/leo_turn_execution_manifest.py"; do
|
||||
[ -f "$f" ] || continue
|
||||
if ! python3 -c "import ast, sys; ast.parse(open(sys.argv[1]).read())" "$f" 2>/dev/null; then
|
||||
echo "SYNTAX ERROR: $f"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "ERROR: $ERRORS files have syntax errors. Fix before deploying."
|
||||
exit 1
|
||||
fi
|
||||
echo "All files pass syntax check."
|
||||
echo ""
|
||||
|
||||
RSYNC_OPTS=(-avz --exclude __pycache__ --exclude '*.pyc' --exclude '*.bak*')
|
||||
if $DRY_RUN; then
|
||||
RSYNC_OPTS+=(--dry-run)
|
||||
echo "=== DRY RUN ==="
|
||||
fi
|
||||
|
||||
echo "=== Pipeline lib/ ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/lib/" "$VPS_HOST:$VPS_PIPELINE/lib/"
|
||||
echo ""
|
||||
|
||||
echo "=== Pipeline top-level ==="
|
||||
for f in teleo-pipeline.py reweave.py fetch_coins.py; do
|
||||
[ -f "$REPO_ROOT/$f" ] || continue
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/$f" "$VPS_HOST:$VPS_PIPELINE/$f"
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=== Telegram bot ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/telegram/" "$VPS_HOST:$VPS_PIPELINE/telegram/"
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/telegram/" "$VPS_HOST:$VPS_TELEGRAM/"
|
||||
echo ""
|
||||
|
||||
echo "=== Tests ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/tests/" "$VPS_HOST:$VPS_PIPELINE/tests/"
|
||||
echo ""
|
||||
|
||||
echo "=== Diagnostics ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/diagnostics/" "$VPS_HOST:$VPS_DIAGNOSTICS/"
|
||||
echo ""
|
||||
|
||||
echo "=== Agent state ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/agent-state/" "$VPS_HOST:$VPS_AGENT_STATE/"
|
||||
echo ""
|
||||
|
||||
echo "=== Leoclean bin ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/hermes-agent/leoclean-bin/" "$VPS_HOST:$VPS_LEOCLEAN_BIN/"
|
||||
echo ""
|
||||
|
||||
echo "=== Leoclean skills ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/hermes-agent/leoclean-skills/vps/" "$VPS_HOST:$VPS_LEOCLEAN_SKILLS/"
|
||||
echo ""
|
||||
|
||||
echo "=== Leoclean plugins ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/hermes-agent/leoclean-plugins/vps/" "$VPS_HOST:$VPS_LEOCLEAN_PLUGINS/"
|
||||
echo ""
|
||||
|
||||
echo "=== Hermes runtime patches ==="
|
||||
LEOCLEAN_POLICY_CHANGED=false
|
||||
if $DRY_RUN; then
|
||||
echo "Would sync hermes-agent/patches/, verify the response-transform hook, and enforce the leoclean learning policy."
|
||||
else
|
||||
ssh "$VPS_HOST" "mkdir -p '$VPS_HERMES_PATCHES'"
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/hermes-agent/patches/" "$VPS_HOST:$VPS_HERMES_PATCHES/"
|
||||
ssh "$VPS_HOST" "python3 '$VPS_HERMES_PATCHES/apply_response_transform_hook.py' '$VPS_HERMES_AGENT/run_agent.py'"
|
||||
POLICY_RESULT=$(ssh "$VPS_HOST" "python3 '$VPS_LEOCLEAN_POLICY_PATCH' '$VPS_LEOCLEAN_CONFIG'")
|
||||
echo "$POLICY_RESULT"
|
||||
if echo "$POLICY_RESULT" | grep -q '"status": "updated"'; then
|
||||
LEOCLEAN_POLICY_CHANGED=true
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Research session ==="
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/research/research-session.sh" "$VPS_HOST:/opt/teleo-eval/research-session.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== Systemd units ==="
|
||||
if $DRY_RUN; then
|
||||
rsync "${RSYNC_OPTS[@]}" "$REPO_ROOT/systemd/" "$VPS_HOST:/tmp/teleo-systemd-dry-run/"
|
||||
else
|
||||
tar -C "$REPO_ROOT/systemd" -cf - . | ssh "$VPS_HOST" "tmpdir=\$(mktemp -d); tar -C \"\$tmpdir\" -xf -; sudo install -m 0644 \"\$tmpdir\"/*.service \"\$tmpdir\"/*.timer '$VPS_SYSTEMD'/; rm -rf \"\$tmpdir\"; sudo systemctl daemon-reload; sudo systemctl enable --now teleo-auto-deploy.timer teleo-agent-healthcheck.timer >/dev/null"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "Dry run complete. No changes made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deploy complete."
|
||||
|
||||
if $RESTART; then
|
||||
echo ""
|
||||
echo "=== Restarting services ==="
|
||||
ssh "$VPS_HOST" "sudo systemctl restart teleo-pipeline teleo-diagnostics; if systemctl is-active --quiet teleo-agent@leo.service; then sudo systemctl restart teleo-agent@leo; fi; if systemctl list-units --all --full teleo-agent@leo-wallet-test.service --no-legend | grep -q .; then sudo systemctl restart teleo-agent@leo-wallet-test; fi; if systemctl is-active --quiet leoclean-gateway.service; then sudo systemctl restart leoclean-gateway; fi"
|
||||
echo "Services restarted."
|
||||
elif $LEOCLEAN_POLICY_CHANGED; then
|
||||
echo ""
|
||||
echo "=== Restarting leoclean for learning-policy change ==="
|
||||
ssh "$VPS_HOST" "if systemctl is-active --quiet leoclean-gateway.service; then sudo systemctl restart leoclean-gateway; fi"
|
||||
echo "Leoclean gateway restarted."
|
||||
fi
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Fix root-owned files before pipeline starts (3rd incident — Rhea, Epimetheus)
|
||||
# Any git op running as root poisons ownership. This catches it at startup.
|
||||
find /opt/teleo-eval/workspaces -not -user teleo -exec chown teleo:teleo {} + 2>/dev/null
|
||||
find /opt/teleo-eval/pipeline -not -user teleo -exec chown teleo:teleo {} + 2>/dev/null
|
||||
find /opt/teleo-eval/entity-queue -not -user teleo -exec chown teleo:teleo {} + 2>/dev/null
|
||||
find /opt/teleo-eval/logs -not -user teleo -exec chown teleo:teleo {} + 2>/dev/null
|
||||
find /opt/teleo-eval/transcripts -not -user teleo -exec chown teleo:teleo {} + 2>/dev/null
|
||||
find /opt/teleo-eval/telegram-archives -not -user teleo -exec chown teleo:teleo {} + 2>/dev/null
|
||||
chown teleo:teleo /opt/teleo-eval/workspaces/.main-worktree.lock 2>/dev/null || true
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install the narrow sudoers rule required by teleo-auto-deploy.service.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SOURCE="$REPO_ROOT/deploy/sudoers/teleo-auto-deploy"
|
||||
TARGET="/etc/sudoers.d/teleo-auto-deploy"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: run as root on the VPS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0440 "$SOURCE" "$TARGET"
|
||||
visudo -cf "$TARGET"
|
||||
echo "Installed $TARGET"
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Read changed repo paths from stdin. Markdown skills are loaded from the synced
|
||||
# profile per turn; runtime code changes still require a gateway restart.
|
||||
restart_required=1
|
||||
while IFS= read -r path || [ -n "$path" ]; do
|
||||
case "$path" in
|
||||
hermes-agent/leoclean-bin/*)
|
||||
restart_required=0
|
||||
;;
|
||||
hermes-agent/leoclean-skills/vps/*.md|hermes-agent/leoclean-skills/vps/*/*.md)
|
||||
;;
|
||||
hermes-agent/leoclean-skills/vps/*)
|
||||
restart_required=0
|
||||
;;
|
||||
hermes-agent/leoclean-plugins/vps/*)
|
||||
restart_required=0
|
||||
;;
|
||||
hermes-agent/patches/*)
|
||||
restart_required=0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit "$restart_required"
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
#!/bin/bash
|
||||
# One-time setup: prepare the bare mirror repo for teleo-infrastructure.
|
||||
#
|
||||
# Prerequisites (must happen BEFORE running this):
|
||||
# 1. GitHub repo `living-ip/teleo-infrastructure` created (manual via web or
|
||||
# `gh repo create` — the deploy PAT is fine-grained to teleo-codex only
|
||||
# and cannot create new repos in the org).
|
||||
# 2. GitHub PAT updated to include push access on the new repo (or rotate
|
||||
# to a classic PAT with `repo` scope covering both).
|
||||
#
|
||||
# This script is idempotent — safe to re-run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MIRROR_BASE="/opt/teleo-eval/mirror"
|
||||
REPO_DIR="$MIRROR_BASE/teleo-infrastructure.git"
|
||||
FORGEJO_URL="http://localhost:3000/teleo/teleo-infrastructure.git"
|
||||
GITHUB_REPO="living-ip/teleo-infrastructure"
|
||||
FORGEJO_TOKEN_FILE="/opt/teleo-eval/secrets/forgejo-admin-token"
|
||||
GITHUB_PAT_FILE="/opt/teleo-eval/secrets/github-pat"
|
||||
|
||||
if [ ! -f "$FORGEJO_TOKEN_FILE" ]; then
|
||||
echo "ERROR: missing $FORGEJO_TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$GITHUB_PAT_FILE" ]; then
|
||||
echo "ERROR: missing $GITHUB_PAT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FORGEJO_TOKEN=$(cat "$FORGEJO_TOKEN_FILE" | tr -d '[:space:]')
|
||||
GITHUB_PAT=$(cat "$GITHUB_PAT_FILE" | tr -d '[:space:]')
|
||||
|
||||
# Sanity check: GitHub repo must exist before we point a remote at it.
|
||||
echo "Verifying GitHub repo $GITHUB_REPO exists..."
|
||||
GH_STATUS=$(curl -sS -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer $GITHUB_PAT" \
|
||||
"https://api.github.com/repos/$GITHUB_REPO")
|
||||
if [ "$GH_STATUS" != "200" ]; then
|
||||
echo "ERROR: GitHub repo $GITHUB_REPO not accessible (HTTP $GH_STATUS)" >&2
|
||||
echo "Create it first: gh repo create $GITHUB_REPO --public --description 'Pipeline + diagnostics infra for the LivingIP collective'" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo " OK — $GITHUB_REPO accessible"
|
||||
|
||||
# Sanity check: Forgejo repo must exist.
|
||||
echo "Verifying Forgejo repo teleo/teleo-infrastructure exists..."
|
||||
FG_STATUS=$(curl -sS -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"http://localhost:3000/api/v1/repos/teleo/teleo-infrastructure")
|
||||
if [ "$FG_STATUS" != "200" ]; then
|
||||
echo "ERROR: Forgejo repo teleo/teleo-infrastructure not accessible (HTTP $FG_STATUS)" >&2
|
||||
exit 3
|
||||
fi
|
||||
echo " OK — Forgejo repo accessible"
|
||||
|
||||
# Init bare mirror if missing
|
||||
if [ -d "$REPO_DIR" ]; then
|
||||
echo "Bare repo already exists at $REPO_DIR — skipping init"
|
||||
else
|
||||
echo "Creating bare repo at $REPO_DIR..."
|
||||
mkdir -p "$REPO_DIR"
|
||||
cd "$REPO_DIR"
|
||||
git init --bare >/dev/null
|
||||
chown -R teleo:teleo "$REPO_DIR"
|
||||
echo " OK — bare repo initialized"
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# Configure remotes (idempotent: set-url succeeds whether remote exists or not)
|
||||
# Forgejo remote (origin convention is reversed in this codebase: origin=GitHub,
|
||||
# forgejo=Forgejo, matching the existing teleo-codex.git layout).
|
||||
FORGEJO_REMOTE_URL="http://github-mirror:${FORGEJO_TOKEN}@localhost:3000/teleo/teleo-infrastructure.git"
|
||||
# NOTE: "m3taversal" is a placeholder username — for fine-grained PATs the
|
||||
# username field is decorative; the token does the auth. Matches the existing
|
||||
# teleo-codex.git remote for consistency. (Ganymede review nit #4.)
|
||||
GITHUB_REMOTE_URL="https://m3taversal:${GITHUB_PAT}@github.com/${GITHUB_REPO}.git"
|
||||
|
||||
if git remote get-url forgejo >/dev/null 2>&1; then
|
||||
git remote set-url forgejo "$FORGEJO_REMOTE_URL"
|
||||
echo " Updated forgejo remote URL"
|
||||
else
|
||||
git remote add forgejo "$FORGEJO_REMOTE_URL"
|
||||
echo " Added forgejo remote"
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "$GITHUB_REMOTE_URL"
|
||||
echo " Updated origin remote URL"
|
||||
else
|
||||
git remote add origin "$GITHUB_REMOTE_URL"
|
||||
echo " Added origin remote"
|
||||
fi
|
||||
|
||||
# Initial fetch from Forgejo
|
||||
echo "Fetching from Forgejo..."
|
||||
git fetch forgejo --prune 2>&1 | sed 's/^/ /'
|
||||
|
||||
# Initial push to GitHub (will populate the empty repo)
|
||||
# main_only mode: push ONLY refs/heads/main + tags, mirroring what sync-mirror.sh
|
||||
# does for this repo on the recurring path. Agent review branches stay Forgejo-only.
|
||||
echo "Pushing initial main + tags to GitHub..."
|
||||
git update-ref refs/heads/main refs/remotes/forgejo/main 2>/dev/null || {
|
||||
echo "ERROR: forgejo/main ref missing — fetch may have failed" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
git push origin "refs/heads/main:refs/heads/main" 2>&1 | sed 's/^/ /' || {
|
||||
echo "WARN: initial push failed — you may need to authorize the PAT for $GITHUB_REPO" >&2
|
||||
}
|
||||
git push origin --tags 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# Final permissions sweep
|
||||
chown -R teleo:teleo "$REPO_DIR"
|
||||
|
||||
echo
|
||||
echo "Setup complete. Verify with:"
|
||||
echo " ssh teleo@77.42.65.182 ls -la $REPO_DIR/refs/heads"
|
||||
echo " /opt/teleo-eval/sync-mirror.sh && tail -50 /opt/teleo-eval/logs/sync.log"
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# Narrow service privileges for teleo-auto-deploy.service, which runs as teleo.
|
||||
# Keep command forms aligned with deploy/auto-deploy.sh.
|
||||
teleo ALL=(root) NOPASSWD: /usr/bin/systemctl restart leoclean-gateway
|
||||
teleo ALL=(root) NOPASSWD: /bin/systemctl restart leoclean-gateway
|
||||
teleo ALL=(root) NOPASSWD: /usr/bin/systemctl restart leoclean-gateway.service
|
||||
teleo ALL=(root) NOPASSWD: /bin/systemctl restart leoclean-gateway.service
|
||||
teleo ALL=(root) NOPASSWD: /usr/bin/systemctl status leoclean-gateway
|
||||
teleo ALL=(root) NOPASSWD: /bin/systemctl status leoclean-gateway
|
||||
teleo ALL=(root) NOPASSWD: /usr/bin/systemctl status leoclean-gateway.service
|
||||
teleo ALL=(root) NOPASSWD: /bin/systemctl status leoclean-gateway.service
|
||||
teleo ALL=(root) NOPASSWD: /usr/bin/journalctl -u leoclean-gateway
|
||||
teleo ALL=(root) NOPASSWD: /bin/journalctl -u leoclean-gateway
|
||||
teleo ALL=(root) NOPASSWD: /usr/bin/journalctl -u leoclean-gateway.service
|
||||
teleo ALL=(root) NOPASSWD: /bin/journalctl -u leoclean-gateway.service
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,107 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sync source-controlled GCP leoclean skills into the GCP parallel runtime.
|
||||
#
|
||||
# Defaults target the non-production GCP leoclean service. This script does not
|
||||
# touch the production Telegram token and does not read or print secrets.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PROJECT="${PROJECT:-teleo-501523}"
|
||||
ZONE="${ZONE:-europe-west6-a}"
|
||||
INSTANCE="${INSTANCE:-teleo-prod-1}"
|
||||
SERVICE="${SERVICE:-leoclean-gcp-prod-parallel.service}"
|
||||
REMOTE_SKILLS_DIR="${REMOTE_SKILLS_DIR:-/home/teleo/.hermes/profiles/leoclean/skills}"
|
||||
SOURCE_DIR="${SOURCE_DIR:-$REPO_ROOT/hermes-agent/leoclean-skills/gcp}"
|
||||
|
||||
DRY_RUN=false
|
||||
RESTART=false
|
||||
VERIFY_ONLY=false
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: deploy/sync-gcp-leoclean-skills.sh [--dry-run] [--restart] [--verify-only]
|
||||
|
||||
Environment overrides:
|
||||
PROJECT GCP project, default teleo-501523
|
||||
ZONE GCP zone, default europe-west6-a
|
||||
INSTANCE GCP VM, default teleo-prod-1
|
||||
SERVICE systemd service, default leoclean-gcp-prod-parallel.service
|
||||
REMOTE_SKILLS_DIR runtime skills dir, default /home/teleo/.hermes/profiles/leoclean/skills
|
||||
SOURCE_DIR local source dir, default hermes-agent/leoclean-skills/gcp
|
||||
USAGE
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--restart) RESTART=true ;;
|
||||
--verify-only) VERIFY_ONLY=true ;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $arg" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "ERROR: source skill directory not found: $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SOURCE_DIR/teleo-kb-bridge/SKILL.md" ]; then
|
||||
echo "ERROR: source teleo-kb-bridge skill not found under $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GCP_SSH=(
|
||||
gcloud compute ssh "$INSTANCE"
|
||||
--project="$PROJECT"
|
||||
--zone="$ZONE"
|
||||
--tunnel-through-iap
|
||||
)
|
||||
|
||||
verify_command=$(cat <<REMOTE
|
||||
set -euo pipefail
|
||||
sudo -n -u teleo grep -nE '/kb/claims/<claim_id>|wrap claim IDs, proposal IDs' '$REMOTE_SKILLS_DIR/teleo-kb-bridge/SKILL.md'
|
||||
systemctl show '$SERVICE' -p ActiveState -p SubState -p NRestarts -p MainPID --no-pager
|
||||
REMOTE
|
||||
)
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "DRY RUN: would stream $SOURCE_DIR to $INSTANCE:$REMOTE_SKILLS_DIR"
|
||||
echo "DRY RUN: project=$PROJECT zone=$ZONE service=$SERVICE restart=$RESTART verify_only=$VERIFY_ONLY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if $VERIFY_ONLY; then
|
||||
"${GCP_SSH[@]}" --command="$verify_command"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REMOTE_TMP="/tmp/teleo-gcp-leoclean-skills-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
sync_command=$(cat <<REMOTE
|
||||
set -euo pipefail
|
||||
remote_tmp='$REMOTE_TMP'
|
||||
remote_skills='$REMOTE_SKILLS_DIR'
|
||||
service='$SERVICE'
|
||||
cleanup() { rm -rf "\$remote_tmp"; }
|
||||
trap cleanup EXIT
|
||||
rm -rf "\$remote_tmp"
|
||||
mkdir -p "\$remote_tmp"
|
||||
tar -C "\$remote_tmp" -xf -
|
||||
sudo install -d -o teleo -g teleo -m 0755 "\$remote_skills"
|
||||
tar -C "\$remote_tmp" -cf - . | sudo tar -C "\$remote_skills" -xf -
|
||||
sudo chown -R teleo:teleo "\$remote_skills"
|
||||
sudo -n -u teleo grep -nE '/kb/claims/<claim_id>|wrap claim IDs, proposal IDs' "\$remote_skills/teleo-kb-bridge/SKILL.md"
|
||||
if [ '$RESTART' = true ]; then
|
||||
sudo systemctl restart "\$service"
|
||||
fi
|
||||
systemctl show "\$service" -p ActiveState -p SubState -p NRestarts -p MainPID --no-pager
|
||||
REMOTE
|
||||
)
|
||||
|
||||
COPYFILE_DISABLE=1 tar --format=ustar -C "$SOURCE_DIR" -cf - . | "${GCP_SSH[@]}" --command="$sync_command"
|
||||
|
|
@ -1,451 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Bidirectional sync: Forgejo (authoritative) <-> GitHub (public mirror)
|
||||
# Forgejo wins on conflict. Runs every 2 minutes via cron.
|
||||
#
|
||||
# Repos handled (see MIRROR_REPOS below):
|
||||
# - teleo-codex (mode=bidirectional): full PR roundtrip — fork PR refs from
|
||||
# GitHub, auto-create Forgejo PR mirrors, link github_pr in pipeline.db.
|
||||
# - teleo-infrastructure (mode=main_only): one-way sync of branches+tags from
|
||||
# Forgejo to GitHub. No PR roundtrip — pipeline doesn't process infra PRs;
|
||||
# external infra PRs land on GitHub for visibility, get reviewed manually.
|
||||
#
|
||||
# Security note: GitHub->Forgejo path is for external contributor convenience.
|
||||
# Never auto-process branches arriving via this path without a PR.
|
||||
# Eval pipeline and extract cron only act on PRs, not raw branches.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG="/opt/teleo-eval/logs/sync.log"
|
||||
LOCKFILE="/tmp/sync-mirror.lock"
|
||||
PIPELINE_DB="/opt/teleo-eval/pipeline/pipeline.db"
|
||||
GITHUB_PAT_FILE="/opt/teleo-eval/secrets/github-pat"
|
||||
|
||||
# (forgejo_owner_repo, github_owner_repo, bare_path, mode)
|
||||
# mode: bidirectional | main_only
|
||||
MIRROR_REPOS=(
|
||||
"teleo/teleo-codex living-ip/teleo-codex /opt/teleo-eval/mirror/teleo-codex.git bidirectional"
|
||||
"teleo/teleo-infrastructure living-ip/teleo-infrastructure /opt/teleo-eval/mirror/teleo-infrastructure.git main_only"
|
||||
)
|
||||
|
||||
REPO_TAG="main"
|
||||
log() { echo "[$(date -Iseconds)] [$REPO_TAG] $1" >> "$LOG"; }
|
||||
|
||||
# Lockfile — prevent concurrent runs (single lock for whole script)
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
pid=$(cat "$LOCKFILE" 2>/dev/null)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$LOCKFILE"
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# sync_repo: process one mirror entry. Sets module-level FORGEJO_REPO,
|
||||
# GITHUB_REPO, REPO_DIR, MODE, REPO_TAG used by inner steps.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
sync_repo() {
|
||||
FORGEJO_REPO="$1" # e.g. teleo/teleo-codex (path on Forgejo)
|
||||
GITHUB_REPO="$2" # e.g. living-ip/teleo-codex (path on GitHub)
|
||||
REPO_DIR="$3" # bare mirror dir
|
||||
MODE="$4" # bidirectional | main_only
|
||||
REPO_TAG="${FORGEJO_REPO##*/}" # short name for log prefix
|
||||
|
||||
# Pre-flight: bare repo must exist
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
log "ERROR: bare repo missing at $REPO_DIR — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Pre-flight: fix permissions if another user touched the mirror dir (Rhea)
|
||||
BAD_PERMS=$(find "$REPO_DIR" ! -user teleo 2>/dev/null | head -1 || true)
|
||||
if [ -n "$BAD_PERMS" ]; then
|
||||
log "Fixing mirror permissions (found: $BAD_PERMS)"
|
||||
chown -R teleo:teleo "$REPO_DIR" 2>/dev/null || true
|
||||
fi
|
||||
cd "$REPO_DIR" || { log "ERROR: cannot cd to $REPO_DIR"; return 0; }
|
||||
|
||||
# Step 1: Fetch from Forgejo (must succeed — it's authoritative)
|
||||
log "Fetching from Forgejo..."
|
||||
if ! git fetch forgejo --prune >> "$LOG" 2>&1; then
|
||||
log "ERROR: Forgejo fetch failed — skipping this repo"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Step 2: Fetch from GitHub (warn on failure, don't abort)
|
||||
log "Fetching from GitHub..."
|
||||
git fetch origin --prune >> "$LOG" 2>&1 || log "WARN: GitHub fetch failed"
|
||||
|
||||
# Step 2.1: Fetch GitHub fork PR refs (bidirectional only)
|
||||
# Fork-based PRs don't create branches on origin — they create refs/pull/N/head.
|
||||
# main_only repos don't accept fork PRs through the mirror path.
|
||||
if [ "$MODE" = "bidirectional" ]; then
|
||||
local PAT
|
||||
PAT=$(cat "$GITHUB_PAT_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
if [ -n "$PAT" ]; then
|
||||
local OPEN_PRS
|
||||
OPEN_PRS=$(curl -sf "https://api.github.com/repos/$GITHUB_REPO/pulls?state=open&per_page=100" \
|
||||
-H "Authorization: token $PAT" 2>/dev/null || echo "[]")
|
||||
echo "$OPEN_PRS" | python3 -c "
|
||||
import sys, json
|
||||
prs = json.load(sys.stdin)
|
||||
for pr in prs:
|
||||
head = pr.get('head', {})
|
||||
base_repo = pr.get('base', {}).get('repo', {}).get('full_name', '')
|
||||
head_repo = head.get('repo', {}) or {}
|
||||
head_full = head_repo.get('full_name', '')
|
||||
if head_full and head_full != base_repo:
|
||||
print(f\"{pr['number']} {head.get('ref', '')} {head.get('sha', '')}\")
|
||||
" 2>/dev/null | while read pr_num branch_name head_sha; do
|
||||
if [ -z "$pr_num" ] || [ -z "$branch_name" ]; then continue; fi
|
||||
local PR_BRANCH="gh-pr-${pr_num}/${branch_name}"
|
||||
local EXISTING
|
||||
EXISTING=$(git rev-parse "refs/heads/$PR_BRANCH" 2>/dev/null || true)
|
||||
if [ "$EXISTING" = "$head_sha" ]; then continue; fi
|
||||
git fetch origin "refs/pull/${pr_num}/head:refs/heads/$PR_BRANCH" >> "$LOG" 2>&1 && \
|
||||
log "Fetched fork PR #$pr_num -> $PR_BRANCH" || \
|
||||
log "WARN: Failed to fetch fork PR #$pr_num"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 2.5: GitHub main -> Forgejo main (ff-only)
|
||||
# If a PR was merged on GitHub, GitHub main is ahead of Forgejo main.
|
||||
# Fast-forward Forgejo main to match — safe because ff-only guarantees no divergence.
|
||||
local GITHUB_MAIN_FF FORGEJO_MAIN_FF
|
||||
GITHUB_MAIN_FF=$(git rev-parse refs/remotes/origin/main 2>/dev/null || true)
|
||||
FORGEJO_MAIN_FF=$(git rev-parse refs/remotes/forgejo/main 2>/dev/null || true)
|
||||
if [ -n "$GITHUB_MAIN_FF" ] && [ -n "$FORGEJO_MAIN_FF" ]; then
|
||||
if [ "$GITHUB_MAIN_FF" != "$FORGEJO_MAIN_FF" ]; then
|
||||
if git merge-base --is-ancestor "$FORGEJO_MAIN_FF" "$GITHUB_MAIN_FF"; then
|
||||
log "GitHub main ($GITHUB_MAIN_FF) ahead of Forgejo main ($FORGEJO_MAIN_FF) — fast-forwarding"
|
||||
git push forgejo "refs/remotes/origin/main:refs/heads/main" >> "$LOG" 2>&1 && \
|
||||
log "Forgejo main fast-forwarded to $GITHUB_MAIN_FF" || \
|
||||
log "WARN: Failed to fast-forward Forgejo main"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: Forgejo -> GitHub (primary direction)
|
||||
log "Syncing Forgejo -> GitHub..."
|
||||
while read branch; do
|
||||
[ "$branch" = "HEAD" ] && continue
|
||||
git update-ref "refs/heads/$branch" "refs/remotes/forgejo/$branch" 2>/dev/null || \
|
||||
log "WARN: Failed to update ref $branch"
|
||||
done < <(git for-each-ref --format="%(refname:lstrip=3)" refs/remotes/forgejo/)
|
||||
|
||||
# Safety: verify Forgejo main descends from GitHub main before force-pushing
|
||||
local GITHUB_MAIN FORGEJO_MAIN PUSH_MAIN
|
||||
GITHUB_MAIN=$(git rev-parse refs/remotes/origin/main 2>/dev/null || true)
|
||||
FORGEJO_MAIN=$(git rev-parse refs/remotes/forgejo/main 2>/dev/null || true)
|
||||
PUSH_MAIN=true
|
||||
if [ -n "$GITHUB_MAIN" ] && [ -n "$FORGEJO_MAIN" ]; then
|
||||
if ! git merge-base --is-ancestor "$GITHUB_MAIN" "$FORGEJO_MAIN"; then
|
||||
log "CRITICAL: Forgejo main is NOT a descendant of GitHub main — skipping main push"
|
||||
log "CRITICAL: GitHub main: $GITHUB_MAIN, Forgejo main: $FORGEJO_MAIN"
|
||||
PUSH_MAIN=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "main_only" ]; then
|
||||
# Infra-style mirror: push main + tags ONLY. Pre-review agent branches
|
||||
# (epimetheus/*, ganymede/*, etc.) carry internal context — agent UUIDs,
|
||||
# in-flight discussion, WIP — and must not land in the public GitHub
|
||||
# history. (Ganymede review, finding #1.)
|
||||
if [ "$PUSH_MAIN" = true ]; then
|
||||
git push origin --force "refs/heads/main:refs/heads/main" >> "$LOG" 2>&1 || \
|
||||
log "WARN: main push to GitHub failed"
|
||||
fi
|
||||
else
|
||||
# Bidirectional mirror (codex): push all branches so external
|
||||
# contributors can fork from any branch, not just main.
|
||||
if [ "$PUSH_MAIN" = true ]; then
|
||||
git push origin --all --force >> "$LOG" 2>&1 || log "WARN: Push to GitHub failed"
|
||||
else
|
||||
# Push all branches except main when main is divergent
|
||||
while read branch; do
|
||||
[ "$branch" = "main" ] && continue
|
||||
[ "$branch" = "HEAD" ] && continue
|
||||
git push origin --force "refs/heads/$branch:refs/heads/$branch" >> "$LOG" 2>&1 || \
|
||||
log "WARN: Failed to push $branch to GitHub"
|
||||
done < <(git for-each-ref --format="%(refname:lstrip=2)" refs/heads/)
|
||||
fi
|
||||
fi
|
||||
git push origin --tags --force >> "$LOG" 2>&1 || log "WARN: Tag push to GitHub failed"
|
||||
|
||||
# Step 4: GitHub -> Forgejo + Forgejo PR auto-create (bidirectional only)
|
||||
if [ "$MODE" = "bidirectional" ]; then
|
||||
sync_github_to_forgejo_with_prs
|
||||
fi
|
||||
|
||||
# Step 6: Divergence alerting (applies to both modes)
|
||||
check_divergence
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4 split out: codex-specific GitHub→Forgejo branch push + PR auto-create.
|
||||
# Reads FORGEJO_REPO, GITHUB_REPO, PIPELINE_DB, REPO_TAG from sync_repo scope.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
sync_github_to_forgejo_with_prs() {
|
||||
log "Checking GitHub-only branches..."
|
||||
local FORGEJO_HOST="http://localhost:3000/api/v1/repos/$FORGEJO_REPO"
|
||||
local GITHUB_ONLY
|
||||
GITHUB_ONLY=$(comm -23 \
|
||||
<(git for-each-ref --format="%(refname:lstrip=3)" refs/remotes/origin/ | grep -v HEAD | sort) \
|
||||
<(git for-each-ref --format="%(refname:lstrip=3)" refs/remotes/forgejo/ | grep -v HEAD | sort))
|
||||
|
||||
if [ -z "$GITHUB_ONLY" ]; then
|
||||
log "No new GitHub-only branches"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local FORGEJO_TOKEN
|
||||
FORGEJO_TOKEN=$(cat /opt/teleo-eval/secrets/forgejo-admin-token 2>/dev/null)
|
||||
|
||||
# Lazy schema for sync-mirror's auto-create tracker. Records (branch, sha)
|
||||
# pairs we've already auto-created PRs for, so the loop below can skip
|
||||
# redundant creates after pipeline merge → _delete_remote_branch →
|
||||
# GitHub-only re-discovery → re-push. Cheap CREATE IF NOT EXISTS on each
|
||||
# cycle; no migration needed because this table is private to sync-mirror.
|
||||
sqlite3 "$PIPELINE_DB" "CREATE TABLE IF NOT EXISTS sync_autocreate_tracker (branch TEXT NOT NULL, sha TEXT NOT NULL, pr_number INTEGER, created_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (branch, sha));" 2>/dev/null || true
|
||||
|
||||
for branch in $GITHUB_ONLY; do
|
||||
# Already-tracked gate: if we've previously auto-created a PR for
|
||||
# this exact (branch, sha), skip the entire push+create sequence.
|
||||
# Closes the empty-PR loop (research and reweave both observed):
|
||||
# pipeline merges PR → _delete_remote_branch on Forgejo → next sync
|
||||
# sees branch GitHub-only (origin still has it) → re-pushes to
|
||||
# Forgejo → HAS_PR misses (Forgejo ?head= broken; closed PRs scroll
|
||||
# past 50-item paginated window) → auto-creates fresh PR → pipeline
|
||||
# merges (empty no-op via cherry-pick / reweave union) → repeat.
|
||||
# Tracker keys on SHA, so legitimate new commits on the same branch
|
||||
# produce a new SHA → tracker miss → auto-create proceeds normally.
|
||||
local BRANCH_SHA TRACKED_PR
|
||||
if [[ "$branch" == gh-pr-* ]]; then
|
||||
BRANCH_SHA=$(git rev-parse "refs/heads/$branch" 2>/dev/null || true)
|
||||
else
|
||||
BRANCH_SHA=$(git rev-parse "refs/remotes/origin/$branch" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -n "$BRANCH_SHA" ]; then
|
||||
# stderr → $LOG so sustained sqlite3 contention surfaces in ops logs
|
||||
# rather than silently falling through to a redundant auto-create.
|
||||
TRACKED_PR=$(sqlite3 "$PIPELINE_DB" "SELECT pr_number FROM sync_autocreate_tracker WHERE branch=$(printf "'%s'" "${branch//\'/\'\'}") AND sha=$(printf "'%s'" "$BRANCH_SHA") LIMIT 1;" 2>>"$LOG" || echo "")
|
||||
if [ -n "$TRACKED_PR" ]; then
|
||||
log "Skip auto-create: $branch SHA $BRANCH_SHA already tracked (PR #$TRACKED_PR)"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
log "New from GitHub: $branch -> Forgejo"
|
||||
# Fork PR branches live as local refs (from Step 2.1), not on origin remote
|
||||
if [[ "$branch" == gh-pr-* ]]; then
|
||||
git push forgejo "refs/heads/$branch:refs/heads/$branch" >> "$LOG" 2>&1 || {
|
||||
log "WARN: Failed to push fork PR branch $branch to Forgejo"
|
||||
continue
|
||||
}
|
||||
else
|
||||
git push forgejo "refs/remotes/origin/$branch:refs/heads/$branch" >> "$LOG" 2>&1 || {
|
||||
log "WARN: Failed to push $branch to Forgejo"
|
||||
continue
|
||||
}
|
||||
fi
|
||||
# Skip pipeline-internal branch prefixes (no PR creation)
|
||||
case "$branch" in
|
||||
extract/*|ingestion/*) continue ;;
|
||||
esac
|
||||
if [ -z "$FORGEJO_TOKEN" ]; then continue; fi
|
||||
|
||||
# Check if PR already exists for this branch (open or closed)
|
||||
# NOTE: Forgejo ?head= filter is broken (ignores head value, returns all PRs).
|
||||
# Workaround: fetch open+closed PRs, pipe to Python, check head.ref.
|
||||
local HAS_PR
|
||||
HAS_PR=$( {
|
||||
curl -sf "$FORGEJO_HOST/pulls?state=open&limit=50" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" 2>/dev/null || echo "[]"
|
||||
echo ""
|
||||
curl -sf "$FORGEJO_HOST/pulls?state=closed&sort=created&limit=50" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" 2>/dev/null || echo "[]"
|
||||
} | python3 -c "
|
||||
import sys, json
|
||||
branch = sys.argv[1]
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line or line == '[]': continue
|
||||
try:
|
||||
for pr in json.loads(line):
|
||||
if pr.get('head', {}).get('ref') == branch:
|
||||
print('yes'); sys.exit(0)
|
||||
except: pass
|
||||
print('no')
|
||||
" "$branch" 2>/dev/null || echo "no")
|
||||
|
||||
if [ "$HAS_PR" = "yes" ]; then continue; fi
|
||||
|
||||
# Build PR title — for fork PRs, use the GitHub PR title
|
||||
local PR_TITLE PAYLOAD RESULT PR_NUM GH_PR_NUM
|
||||
if [[ "$branch" == gh-pr-* ]]; then
|
||||
local FORK_GH_NUM PAT_T
|
||||
FORK_GH_NUM=$(echo "$branch" | sed 's|gh-pr-\([0-9]*\)/.*|\1|')
|
||||
PAT_T=$(cat "$GITHUB_PAT_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
PR_TITLE=$(curl -sf "https://api.github.com/repos/$GITHUB_REPO/pulls/$FORK_GH_NUM" \
|
||||
-H "Authorization: token $PAT_T" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('title',''))" 2>/dev/null || true)
|
||||
[ -z "$PR_TITLE" ] && PR_TITLE=$(echo "$branch" | sed 's|/|: |;s/-/ /g')
|
||||
else
|
||||
PR_TITLE=$(echo "$branch" | sed 's|/|: |;s/-/ /g')
|
||||
fi
|
||||
PAYLOAD=$(python3 -c "import sys,json; print(json.dumps({'title':sys.argv[1],'head':sys.argv[2],'base':'main'}))" "$PR_TITLE" "$branch")
|
||||
RESULT=$(curl -sf -X POST "$FORGEJO_HOST/pulls" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" 2>/dev/null || echo "")
|
||||
PR_NUM=$(echo "$RESULT" | grep -o '"number":[0-9]*' | head -1 | grep -o "[0-9]*" || true)
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
log "WARN: Failed to auto-create PR for $branch"
|
||||
continue
|
||||
fi
|
||||
log "Auto-created PR #$PR_NUM on Forgejo for $branch"
|
||||
|
||||
# Record (branch, sha, pr_number) so the tracker gate above can short-
|
||||
# circuit the next time we see this exact (branch, sha) combination.
|
||||
# INSERT OR IGNORE: idempotent if a concurrent run already inserted.
|
||||
# WARN log on failure: silent INSERT failure under sustained sqlite3
|
||||
# contention would mask the loop reappearing on the next cycle (HAS_PR
|
||||
# only saves us while the closed PR is in the 50-item pagination window).
|
||||
if [ -n "$BRANCH_SHA" ] && [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
|
||||
if ! sqlite3 "$PIPELINE_DB" "INSERT OR IGNORE INTO sync_autocreate_tracker (branch, sha, pr_number) VALUES ($(printf "'%s'" "${branch//\'/\'\'}"), $(printf "'%s'" "$BRANCH_SHA"), $PR_NUM);" 2>>"$LOG"; then
|
||||
log "WARN: tracker insert failed for $branch SHA $BRANCH_SHA (PR #$PR_NUM) — duplicate auto-create possible next cycle"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4.5: Link GitHub PR to Forgejo PR in pipeline DB
|
||||
if [[ "$branch" == gh-pr-* ]]; then
|
||||
GH_PR_NUM=$(echo "$branch" | sed 's|gh-pr-\([0-9]*\)/.*|\1|')
|
||||
else
|
||||
local PAT
|
||||
PAT=$(cat "$GITHUB_PAT_FILE" 2>/dev/null | tr -d '[:space:]')
|
||||
GH_PR_NUM=""
|
||||
if [ -n "$PAT" ]; then
|
||||
GH_PR_NUM=$(curl -sf "https://api.github.com/repos/$GITHUB_REPO/pulls?head=living-ip:$branch&state=all" \
|
||||
-H "Authorization: token $PAT" 2>/dev/null | \
|
||||
python3 -c "import sys,json; prs=json.load(sys.stdin); print(prs[0]['number'] if prs else '')" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
if [[ "$GH_PR_NUM" =~ ^[0-9]+$ ]] && [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
|
||||
sqlite3 "$PIPELINE_DB" "UPDATE prs SET github_pr = $GH_PR_NUM, source_channel = 'github' WHERE number = $PR_NUM;" 2>/dev/null && \
|
||||
log "Linked GitHub PR #$GH_PR_NUM -> Forgejo PR #$PR_NUM" || \
|
||||
log "WARN: Failed to link GitHub PR #$GH_PR_NUM to Forgejo PR #$PR_NUM in DB"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 6 split out: divergence alerting. Per-repo state file so each repo
|
||||
# has its own divergence counter and alert state.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_divergence() {
|
||||
local DIVERGENCE_FILE="/opt/teleo-eval/logs/.divergence-count.${REPO_TAG}"
|
||||
git fetch forgejo main --quiet 2>/dev/null || true
|
||||
git fetch origin main --quiet 2>/dev/null || true
|
||||
local GH_MAIN_FINAL FG_MAIN_FINAL
|
||||
GH_MAIN_FINAL=$(git rev-parse refs/remotes/origin/main 2>/dev/null || true)
|
||||
FG_MAIN_FINAL=$(git rev-parse refs/remotes/forgejo/main 2>/dev/null || true)
|
||||
|
||||
if [ -n "$GH_MAIN_FINAL" ] && [ -n "$FG_MAIN_FINAL" ] && [ "$GH_MAIN_FINAL" != "$FG_MAIN_FINAL" ]; then
|
||||
local PREV
|
||||
PREV=$(cat "$DIVERGENCE_FILE" 2>/dev/null || echo "0")
|
||||
if [ "$PREV" = "alerted" ]; then
|
||||
log "DIVERGENCE: still diverged (already alerted)"
|
||||
else
|
||||
local COUNT=$((PREV + 1))
|
||||
echo "$COUNT" > "$DIVERGENCE_FILE"
|
||||
log "DIVERGENCE: cycle $COUNT — GitHub=$GH_MAIN_FINAL Forgejo=$FG_MAIN_FINAL"
|
||||
if [ "$COUNT" -ge 2 ]; then
|
||||
local BOT_TOKEN ADMIN_CHAT
|
||||
BOT_TOKEN=$(cat /opt/teleo-eval/secrets/telegram-bot-token 2>/dev/null || true)
|
||||
ADMIN_CHAT=$(cat /opt/teleo-eval/secrets/admin-chat-id 2>/dev/null || true)
|
||||
if [ -n "$BOT_TOKEN" ] && [ -n "$ADMIN_CHAT" ]; then
|
||||
local ALERT_MSG
|
||||
ALERT_MSG=$(python3 -c "
|
||||
import json, sys
|
||||
msg = '⚠️ Mirror divergence detected (' + sys.argv[5] + ')\\n\\n'
|
||||
msg += f'GitHub main: {sys.argv[1][:8]}\\n'
|
||||
msg += f'Forgejo main: {sys.argv[2][:8]}\\n'
|
||||
msg += f'Diverged for {sys.argv[3]} consecutive cycles ({int(sys.argv[3])*2} min)\\n\\n'
|
||||
msg += 'Check sync-mirror.sh logs: /opt/teleo-eval/logs/sync.log'
|
||||
print(json.dumps({'chat_id': sys.argv[4], 'text': msg, 'parse_mode': 'HTML'}))
|
||||
" "$GH_MAIN_FINAL" "$FG_MAIN_FINAL" "$COUNT" "$ADMIN_CHAT" "$REPO_TAG")
|
||||
if curl -sf -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$ALERT_MSG" >> "$LOG" 2>&1; then
|
||||
log "DIVERGENCE: alert sent to admin"
|
||||
echo "alerted" > "$DIVERGENCE_FILE"
|
||||
else
|
||||
log "WARN: Failed to send divergence alert (will retry next cycle)"
|
||||
fi
|
||||
else
|
||||
log "WARN: Cannot send divergence alert — missing bot token or admin chat ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ -f "$DIVERGENCE_FILE" ]; then
|
||||
local PREV
|
||||
PREV=$(cat "$DIVERGENCE_FILE" 2>/dev/null || echo "0")
|
||||
if [ "$PREV" != "0" ]; then
|
||||
log "DIVERGENCE: resolved — repos back in sync"
|
||||
fi
|
||||
rm -f "$DIVERGENCE_FILE"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main: process each configured mirror in sequence.
|
||||
# A failure on one repo doesn't block subsequent repos — sync_repo returns 0
|
||||
# on most error paths to keep the loop going.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
REPO_TAG="main"
|
||||
log "Starting sync cycle"
|
||||
|
||||
# Step 0: self-heal any gh-pr-* PR rows missing github_pr.
|
||||
# Runs FIRST — before per-repo work (branch-mirror loop, auto-create-PR block).
|
||||
# Recovers from races/transient failures in Step 4.5's one-shot link UPDATE.
|
||||
# Idempotent: SELECT empty when clean, zero-cost path. Same SELECT/UPDATE
|
||||
# heals historical orphans (PR 4066 picked up on first cron tick post-deploy)
|
||||
# and future races on subsequent ticks. The branch name encodes the GitHub PR
|
||||
# number deterministically (gh-pr-{N}/...) so no API call is required.
|
||||
if [ -f "$PIPELINE_DB" ]; then
|
||||
sqlite3 -separator '|' "$PIPELINE_DB" \
|
||||
"SELECT number, branch FROM prs WHERE branch LIKE 'gh-pr-%' AND github_pr IS NULL;" \
|
||||
2>/dev/null | while IFS='|' read -r pr_num branch; do
|
||||
# Regex requires >=1 digit — empty/non-numeric branches fail to parse here,
|
||||
# not just at the empty-guard below. Keeps SQL-integer-safety load-bearing
|
||||
# on the regex alone. [0-9][0-9]* is the portable BRE form of [0-9]+,
|
||||
# works on both GNU sed (VPS) and BSD sed (dev macs).
|
||||
gh_pr_num=$(echo "$branch" | sed -n 's|^gh-pr-\([0-9][0-9]*\)/.*|\1|p')
|
||||
[ -z "$gh_pr_num" ] && continue
|
||||
# Both interpolated values are integer-validated upstream (pr_num from
|
||||
# INTEGER `number` column, gh_pr_num from regex above). No parametric
|
||||
# binding available in bash sqlite3 — safety relies on those invariants.
|
||||
if sqlite3 "$PIPELINE_DB" \
|
||||
"UPDATE prs SET github_pr = $gh_pr_num, source_channel = 'github' WHERE number = $pr_num;" \
|
||||
2>/dev/null; then
|
||||
log "self-heal: linked Forgejo PR #$pr_num -> GitHub PR #$gh_pr_num"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
for entry in "${MIRROR_REPOS[@]}"; do
|
||||
# Read the 4 fields. `read` splits on $IFS (whitespace) by default.
|
||||
read -r forgejo_repo github_repo bare_path mode <<< "$entry"
|
||||
sync_repo "$forgejo_repo" "$github_repo" "$bare_path" "$mode"
|
||||
done
|
||||
|
||||
REPO_TAG="main"
|
||||
log "Sync cycle complete"
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
"""
|
||||
/api/activity endpoint for diagnostics service.
|
||||
|
||||
Serves per-operation events for the dashboard v2 timeline hero panel.
|
||||
Derives events from the prs table (per-PR granularity) and audit_log
|
||||
(pipeline-level ops). Cursor-based pagination via timestamp.
|
||||
|
||||
Integration: add route and handler to app.py:
|
||||
app.router.add_get('/api/activity', handle_activity)
|
||||
|
||||
Contract (endpoint #7):
|
||||
GET /api/activity?limit=100&cursor=<ISO-timestamp>
|
||||
Response: {
|
||||
events: [{timestamp, agent, operation, target, domain, description, status, pr_number}],
|
||||
limit: int,
|
||||
cursor: string|null,
|
||||
has_more: bool
|
||||
}
|
||||
|
||||
Data sources:
|
||||
- prs table: number, status, domain, agent, created_at, merged_at, branch, source_path
|
||||
- audit_log table: timestamp, stage, event, detail
|
||||
- contributors table: handle, display_name (for agent name resolution)
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
|
||||
# Non-merged statuses map directly to operation — no semantic classification yet.
|
||||
NON_MERGED_STATUS_TO_OPERATION = {
|
||||
'approved': 'new', # about to become knowledge
|
||||
'open': 'extract', # cyan — new extraction in progress
|
||||
'validating': 'extract', # cyan — being validated
|
||||
'reviewing': 'extract', # cyan — under review
|
||||
'merging': 'new', # green — merge in progress
|
||||
'closed': 'infra', # grey — closed/rejected
|
||||
'zombie': 'infra', # grey — stale
|
||||
'conflict': 'challenge', # red-orange — conflict detected
|
||||
}
|
||||
|
||||
# Maintenance commit_types that land on main but don't represent new knowledge.
|
||||
_MAINTENANCE_COMMIT_TYPES = {'fix', 'pipeline', 'reweave'}
|
||||
|
||||
|
||||
def classify_pr_operation(status, commit_type, branch, description=None):
|
||||
"""Derive a Timeline operation from a PR row.
|
||||
|
||||
Priority order for MERGED PRs (commit_type wins over branch prefix —
|
||||
extract/* branches with commit_type='enrich' or 'challenge' classify
|
||||
by commit_type, matching the contributor-role wiring fix):
|
||||
1. commit_type == 'challenge' OR branch.startswith('challenge/') OR
|
||||
description contains 'challenged_by' → 'challenge'
|
||||
2. commit_type == 'enrich' OR branch.startswith('enrich/' | 'reweave/')
|
||||
→ 'enrich'
|
||||
3. commit_type in _MAINTENANCE_COMMIT_TYPES → 'infra'
|
||||
4. default (commit_type='knowledge'|'extract'|'research'|'entity' or
|
||||
anything else) → 'new'
|
||||
|
||||
For non-merged PRs, falls back to NON_MERGED_STATUS_TO_OPERATION.
|
||||
"""
|
||||
commit_type = (commit_type or '').lower()
|
||||
branch = branch or ''
|
||||
description_lower = (description or '').lower()
|
||||
|
||||
if status != 'merged':
|
||||
return NON_MERGED_STATUS_TO_OPERATION.get(status, 'infra')
|
||||
|
||||
# Challenge takes precedence — the signal is inherently more specific.
|
||||
if (commit_type == 'challenge'
|
||||
or branch.startswith('challenge/')
|
||||
or 'challenged_by' in description_lower):
|
||||
return 'challenge'
|
||||
|
||||
if (commit_type == 'enrich'
|
||||
or branch.startswith('enrich/')
|
||||
or branch.startswith('reweave/')):
|
||||
return 'enrich'
|
||||
|
||||
if commit_type in _MAINTENANCE_COMMIT_TYPES:
|
||||
return 'infra'
|
||||
|
||||
# Default: legacy 'knowledge', new 'extract', 'research', 'entity',
|
||||
# unknown/null commit_type → treat as new knowledge.
|
||||
return 'new'
|
||||
|
||||
# Map audit_log stage to operation type
|
||||
STAGE_TO_OPERATION = {
|
||||
'ingest': 'extract',
|
||||
'extract': 'extract',
|
||||
'validate': 'infra',
|
||||
'evaluate': 'infra',
|
||||
'merge': 'new',
|
||||
'reject': 'infra',
|
||||
'breaker': 'challenge',
|
||||
}
|
||||
|
||||
|
||||
def pr_description(row):
|
||||
"""Generate human-readable description from a PR row."""
|
||||
status = row['status']
|
||||
domain = row['domain'] or 'unknown'
|
||||
branch = row['branch'] or ''
|
||||
|
||||
# Extract a meaningful target from the branch name
|
||||
# Branch format is typically: agent-name/claims-description
|
||||
target = branch.split('/')[-1] if '/' in branch else branch
|
||||
|
||||
# Infer agent from branch prefix if not in the row
|
||||
branch_agent = branch.split('/')[0] if '/' in branch else None
|
||||
|
||||
# Build a richer description with domain context
|
||||
domain_tag = f" [{domain}]" if domain and domain != 'unknown' and domain != 'general' else ''
|
||||
|
||||
templates = {
|
||||
'merged': f"Merged{domain_tag}: {target}",
|
||||
'approved': f"Approved{domain_tag}: {target}",
|
||||
'open': f"Opened{domain_tag}: {target}",
|
||||
'validating': f"Validating{domain_tag}: {target}",
|
||||
'reviewing': f"Reviewing{domain_tag}: {target}",
|
||||
'merging': f"Merging{domain_tag}: {target}",
|
||||
'closed': f"Closed{domain_tag}: {target}",
|
||||
'zombie': f"Stale{domain_tag}: {target}",
|
||||
'conflict': f"Conflict{domain_tag}: {target}",
|
||||
}
|
||||
|
||||
return templates.get(status, f"PR #{row['number']}{domain_tag}: {target}")
|
||||
|
||||
|
||||
def audit_description(row):
|
||||
"""Generate human-readable description from an audit_log row."""
|
||||
stage = row['stage'] or ''
|
||||
event = row['event'] or ''
|
||||
detail = row['detail'] or ''
|
||||
|
||||
# Try to parse detail as JSON
|
||||
if detail:
|
||||
try:
|
||||
detail_obj = json.loads(detail)
|
||||
if isinstance(detail_obj, dict):
|
||||
msg = detail_obj.get('message') or detail_obj.get('reason', '')
|
||||
if msg:
|
||||
return f"[{stage}] {msg}"[:150]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if event:
|
||||
desc = f"[{stage}] {event}"
|
||||
if detail and len(detail) < 80:
|
||||
desc += f" — {detail}"
|
||||
return desc[:150]
|
||||
|
||||
return f"[{stage}] pipeline event"
|
||||
|
||||
|
||||
async def handle_activity(request):
|
||||
"""Handler for GET /api/activity.
|
||||
|
||||
Query params:
|
||||
limit (int, default 100, max 500): number of events to return
|
||||
cursor (ISO timestamp): return events older than this timestamp
|
||||
type (str, optional): comma-separated operation types to include
|
||||
(extract|new|enrich|challenge|infra). If absent, returns all types.
|
||||
|
||||
Derives events from two sources:
|
||||
1. prs table — per-PR events with domain, agent, status
|
||||
2. audit_log — pipeline-level operational events
|
||||
|
||||
Events are merged and sorted by timestamp descending (most recent first).
|
||||
"""
|
||||
try:
|
||||
limit = min(int(request.query.get('limit', 100)), 500)
|
||||
except (ValueError, TypeError):
|
||||
limit = 100
|
||||
|
||||
cursor = request.query.get('cursor')
|
||||
type_param = request.query.get('type', '').strip()
|
||||
allowed_ops = None
|
||||
if type_param:
|
||||
allowed_ops = {t.strip() for t in type_param.split(',') if t.strip()}
|
||||
if not allowed_ops:
|
||||
allowed_ops = None
|
||||
|
||||
db_path = request.app['db_path']
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
events = []
|
||||
|
||||
# Source 1: PR events (primary — these have the granularity we need)
|
||||
# Each PR generates events at created_at and merged_at timestamps
|
||||
pr_query = """
|
||||
SELECT number, status, domain, agent, branch, source_path,
|
||||
created_at, merged_at, source_channel, commit_type,
|
||||
description
|
||||
FROM prs
|
||||
WHERE {where_clause}
|
||||
ORDER BY COALESCE(merged_at, created_at) DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
# Over-fetch when filtering by type so we have enough matching rows after
|
||||
# post-build filtering. Cap at 2000 to avoid runaway queries.
|
||||
fetch_limit = min(2000, limit * 5) if allowed_ops else limit + 1
|
||||
|
||||
if cursor:
|
||||
rows = conn.execute(
|
||||
pr_query.format(where_clause="COALESCE(merged_at, created_at) < ?"),
|
||||
(cursor, fetch_limit)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
pr_query.format(where_clause="1=1"),
|
||||
(fetch_limit,)
|
||||
).fetchall()
|
||||
|
||||
# Known knowledge agents for branch-prefix inference
|
||||
knowledge_agents = {'rio', 'clay', 'theseus', 'vida', 'astra', 'leo'}
|
||||
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
operation = classify_pr_operation(
|
||||
row_dict['status'],
|
||||
row_dict.get('commit_type'),
|
||||
row_dict.get('branch'),
|
||||
row_dict.get('description'),
|
||||
)
|
||||
if allowed_ops and operation not in allowed_ops:
|
||||
continue
|
||||
description = pr_description(row_dict)
|
||||
|
||||
# Use merged_at if available (more interesting event), else created_at
|
||||
timestamp = row_dict['merged_at'] or row_dict['created_at']
|
||||
|
||||
# Infer agent from branch prefix if DB column is null
|
||||
# Branch format: agent-name/claims-description
|
||||
agent = row_dict['agent']
|
||||
if not agent and row_dict.get('branch'):
|
||||
prefix = row_dict['branch'].split('/')[0].lower()
|
||||
if prefix in knowledge_agents:
|
||||
agent = prefix
|
||||
|
||||
events.append({
|
||||
'timestamp': timestamp,
|
||||
'agent': agent,
|
||||
'operation': operation,
|
||||
'target': (row_dict['branch'] or '').split('/')[-1] if row_dict['branch'] else None,
|
||||
'domain': row_dict['domain'],
|
||||
'description': description,
|
||||
'status': row_dict['status'],
|
||||
'pr_number': row_dict['number'],
|
||||
'source_channel': row_dict.get('source_channel') or 'unknown',
|
||||
})
|
||||
|
||||
# Source 2: Audit log events (secondary — pipeline-level)
|
||||
# Only include if we haven't hit our limit from PRs alone
|
||||
if len(events) < limit:
|
||||
remaining = limit - len(events) + 1
|
||||
audit_query = """
|
||||
SELECT timestamp, stage, event, detail
|
||||
FROM audit_log
|
||||
WHERE {where_clause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
if cursor:
|
||||
audit_rows = conn.execute(
|
||||
audit_query.format(where_clause="timestamp < ?"),
|
||||
(cursor, remaining)
|
||||
).fetchall()
|
||||
else:
|
||||
audit_rows = conn.execute(
|
||||
audit_query.format(where_clause="1=1"),
|
||||
(remaining,)
|
||||
).fetchall()
|
||||
|
||||
for row in audit_rows:
|
||||
row_dict = dict(row)
|
||||
operation = STAGE_TO_OPERATION.get(row_dict['stage'], 'infra')
|
||||
if allowed_ops and operation not in allowed_ops:
|
||||
continue
|
||||
description = audit_description(row_dict)
|
||||
|
||||
events.append({
|
||||
'timestamp': row_dict['timestamp'],
|
||||
'agent': None, # audit_log has no agent column
|
||||
'operation': operation,
|
||||
'target': None,
|
||||
'domain': None,
|
||||
'description': description,
|
||||
'status': None,
|
||||
'pr_number': None,
|
||||
'source_channel': None, # audit events not tied to a PR
|
||||
})
|
||||
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
return web.json_response({'error': f'Database error: {e}'}, status=500)
|
||||
|
||||
# Sort all events by timestamp descending
|
||||
events.sort(key=lambda e: e['timestamp'] or '', reverse=True)
|
||||
|
||||
# Apply limit and check for more
|
||||
has_more = len(events) > limit
|
||||
events = events[:limit]
|
||||
|
||||
# Cursor is the timestamp of the last event returned
|
||||
next_cursor = events[-1]['timestamp'] if events else None
|
||||
|
||||
return web.json_response({
|
||||
'events': events,
|
||||
'limit': limit,
|
||||
'cursor': next_cursor,
|
||||
'has_more': has_more,
|
||||
})
|
||||
|
||||
|
||||
# --- Integration snippet for app.py ---
|
||||
# Add to your route setup:
|
||||
#
|
||||
# from activity_endpoint import handle_activity
|
||||
# app.router.add_get('/api/activity', handle_activity)
|
||||
#
|
||||
# Requires: app['db_path'] set to the pipeline.db path
|
||||
# e.g.: app['db_path'] = '/opt/teleo-eval/pipeline/pipeline.db'
|
||||
|
|
@ -1,423 +0,0 @@
|
|||
"""Activity feed API — serves contribution events from pipeline.db."""
|
||||
import re
|
||||
import sqlite3
|
||||
import math
|
||||
import time
|
||||
from aiohttp import web
|
||||
|
||||
DB_PATH = "/opt/teleo-eval/pipeline/pipeline.db"
|
||||
_cache = {"data": None, "ts": 0}
|
||||
CACHE_TTL = 60 # 1 minute — activity should feel fresh
|
||||
|
||||
# commit_types we surface in the activity feed. `pipeline` is system
|
||||
# maintenance (reweave/fix auto-runs, zombie cleanup) and stays hidden.
|
||||
_FEED_COMMIT_TYPES = ("knowledge", "enrich", "challenge", "research", "entity", "extract", "reweave")
|
||||
|
||||
# Source-archive slugs follow YYYY-MM-DD-publisher-topic-HASH4 — they're
|
||||
# inbox archive filenames, not claim slugs. Used as a fallback signal when
|
||||
# branch/description heuristics miss (e.g. populated descriptions that
|
||||
# happen to be source titles, not claim insights).
|
||||
_SOURCE_SLUG_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-.+-[a-f0-9]{4}$")
|
||||
|
||||
|
||||
def _get_conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout = 10000")
|
||||
return conn
|
||||
|
||||
|
||||
def _is_source_slug(slug):
|
||||
return bool(slug and _SOURCE_SLUG_PATTERN.match(slug))
|
||||
|
||||
|
||||
def _classify_event(branch, description, commit_type, candidate_slug=None):
|
||||
"""Return one of: create | enrich | challenge | source | session_digest | None.
|
||||
|
||||
Source-archive PRs are extract/* branches that filed a source into
|
||||
inbox/archive/ but didn't produce a claim. Session-digest PRs are
|
||||
agent research/entity commits with no per-claim description — they
|
||||
represent session-level rollups, not specific knowledge artifacts.
|
||||
"""
|
||||
commit_type_l = (commit_type or "").lower()
|
||||
branch = branch or ""
|
||||
description_lower = (description or "").lower()
|
||||
has_desc = bool(description and description.strip())
|
||||
|
||||
if commit_type_l not in _FEED_COMMIT_TYPES:
|
||||
return None
|
||||
|
||||
# Explicit challenge signals win first.
|
||||
if (commit_type_l == "challenge"
|
||||
or branch.startswith("challenge/")
|
||||
or "challenged_by" in description_lower):
|
||||
return "challenge"
|
||||
|
||||
# Enrichment: reweave edge-connects, enrich/ branches, or commit_type=enrich.
|
||||
if (commit_type_l == "enrich"
|
||||
or branch.startswith("enrich/")
|
||||
or branch.startswith("reweave/")):
|
||||
return "enrich"
|
||||
|
||||
# Research and entity commits with no description are session-level
|
||||
# rollups (e.g. astra/research-2026-05-11). They have no claim to
|
||||
# link to — surface as session_digest, not as a phantom create.
|
||||
if commit_type_l in ("research", "entity") and not has_desc:
|
||||
return "session_digest"
|
||||
|
||||
# Source-only: extract/* with no claim description means inbox archive
|
||||
# landed but no domain claim was written.
|
||||
if branch.startswith("extract/") and not has_desc:
|
||||
return "source"
|
||||
|
||||
# Belt-and-suspenders: if the slug we'd surface to the frontend looks
|
||||
# like an inbox archive filename (date-prefix-hash), treat as source
|
||||
# regardless of branch/commit_type/description state. Catches cases
|
||||
# where description leaked but is just a source title, not a claim.
|
||||
if _is_source_slug(candidate_slug):
|
||||
return "source"
|
||||
|
||||
# Everything else with a description is a new claim.
|
||||
return "create"
|
||||
|
||||
|
||||
# Internal classifier value -> canonical `kind` enum returned to frontend.
|
||||
_KIND_MAP = {
|
||||
"create": "claim_merged",
|
||||
"enrich": "claim_enriched",
|
||||
"challenge": "claim_challenged",
|
||||
"source": "source_archived",
|
||||
"session_digest": "session_digest",
|
||||
}
|
||||
|
||||
|
||||
def _archive_slug_from_branch(branch):
|
||||
"""For extract/YYYY-MM-DD-...-HASH4, return YYYY-MM-DD-... (keep date,
|
||||
drop the 4-hex hash suffix). Matches inbox/archive filename convention.
|
||||
"""
|
||||
if not branch or "/" not in branch:
|
||||
return ""
|
||||
slug = branch.split("/", 1)[1]
|
||||
return re.sub(r"-[a-f0-9]{4}$", "", slug)
|
||||
|
||||
|
||||
def _source_target_url(domain, archive_slug):
|
||||
"""Forgejo blob URL for an archived source file. Falls back to the
|
||||
repo-wide inbox/archive directory when domain is unknown so the link
|
||||
still resolves to something useful instead of a 404.
|
||||
"""
|
||||
if not archive_slug:
|
||||
return None
|
||||
domain = (domain or "").strip()
|
||||
if not domain or domain == "unknown":
|
||||
return "https://git.livingip.xyz/teleo/teleo-codex/src/branch/main/inbox/archive"
|
||||
return (
|
||||
"https://git.livingip.xyz/teleo/teleo-codex/src/branch/main/inbox/archive/"
|
||||
f"{domain}/{archive_slug}.md"
|
||||
)
|
||||
|
||||
|
||||
def _claim_target_url(claim_slug):
|
||||
if not claim_slug:
|
||||
return None
|
||||
return f"/claims/{claim_slug}"
|
||||
|
||||
|
||||
# Canonical clickthrough URL for an activity-feed event.
|
||||
#
|
||||
# Every merged PR in the pipeline.db `prs` table lives on Forgejo at
|
||||
# git.livingip.xyz/teleo/teleo-codex/pulls/{number}. A small subset (3 of
|
||||
# 4094 as of 2026-05-13) was additionally mirrored to GitHub and has
|
||||
# prs.github_pr populated. Prefer GitHub when available (more public-facing
|
||||
# surface), fall back to Forgejo so every row has a real destination
|
||||
# instead of None (which makes the frontend whole-row overlay no-op and
|
||||
# leaves pipeline-attributed events looking dead-on-click).
|
||||
def _pr_url(pr_number, github_pr):
|
||||
if github_pr:
|
||||
return f"https://github.com/living-ip/teleo-codex/pull/{github_pr}"
|
||||
if pr_number:
|
||||
return f"https://git.livingip.xyz/teleo/teleo-codex/pulls/{pr_number}"
|
||||
return None
|
||||
|
||||
|
||||
# Canonicalize contributor labels so frontend links resolve to real
|
||||
# /contributors/{handle} pages. Pipeline writers (extract.py, manual edits,
|
||||
# the old backfill_submitted_by.py) historically wrote mixed-case agent
|
||||
# names with a trailing decorator into prs.submitted_by — e.g.
|
||||
# "Vida (self-directed)", "pipeline (reweave)", or "@m3taversal".
|
||||
# These decorated strings do not exist as contributors and 404 the profile
|
||||
# page. Strip the trailing parenthetical wholesale: valid handles match
|
||||
# ^[a-z0-9][a-z0-9_-]{0,38}$ (see pipeline/lib/attribution._HANDLE_RE) and
|
||||
# cannot contain parens, so this is lossless.
|
||||
_TRAILING_PAREN_RE = re.compile(r"\s*\([^)]*\)\s*$")
|
||||
|
||||
|
||||
def _canonicalize(raw):
|
||||
if not raw:
|
||||
return ""
|
||||
h = raw.strip().lower().lstrip("@")
|
||||
h = _TRAILING_PAREN_RE.sub("", h).strip()
|
||||
return h
|
||||
|
||||
|
||||
def _normalize_contributor(submitted_by, agent):
|
||||
name = _canonicalize(submitted_by)
|
||||
if name:
|
||||
return name
|
||||
name = _canonicalize(agent)
|
||||
if name and name != "pipeline":
|
||||
return name
|
||||
return "pipeline"
|
||||
|
||||
|
||||
def _summary_from_branch(branch):
|
||||
if not branch:
|
||||
return ""
|
||||
parts = branch.split("/", 1)
|
||||
if len(parts) < 2:
|
||||
return ""
|
||||
slug = parts[1]
|
||||
slug = re.sub(r"^[\d-]+-", "", slug) # strip date prefix
|
||||
slug = re.sub(r"-[a-f0-9]{4}$", "", slug) # strip hash suffix
|
||||
return slug.replace("-", " ").strip().capitalize()
|
||||
|
||||
|
||||
def _extract_claim_slugs(description, branch=None):
|
||||
if not description:
|
||||
if branch:
|
||||
parts = branch.split("/", 1)
|
||||
if len(parts) > 1:
|
||||
return [parts[1]]
|
||||
return []
|
||||
titles = [t.strip() for t in description.split("|") if t.strip()]
|
||||
slugs = []
|
||||
for title in titles:
|
||||
slug = title.lower().strip()
|
||||
slug = "".join(c if c.isalnum() or c in (" ", "-") else "" for c in slug)
|
||||
slug = slug.replace(" ", "-").strip("-")
|
||||
if len(slug) > 10:
|
||||
slugs.append(slug)
|
||||
return slugs
|
||||
|
||||
|
||||
def _hot_score(challenge_count, enrich_count, signal_count, hours_since):
|
||||
numerator = challenge_count * 3 + enrich_count * 2 + signal_count
|
||||
denominator = max(hours_since, 0.5) ** 1.5
|
||||
return numerator / denominator
|
||||
|
||||
|
||||
def _build_events():
|
||||
conn = _get_conn()
|
||||
try:
|
||||
placeholders = ",".join("?" * len(_FEED_COMMIT_TYPES))
|
||||
rows = conn.execute(f"""
|
||||
SELECT p.number, p.branch, p.domain, p.agent, p.submitted_by,
|
||||
p.merged_at, p.description, p.commit_type, p.cost_usd,
|
||||
p.source_channel, p.source_path, p.github_pr
|
||||
FROM prs p
|
||||
WHERE p.status = 'merged'
|
||||
AND p.commit_type IN ({placeholders})
|
||||
AND p.merged_at IS NOT NULL
|
||||
ORDER BY p.merged_at DESC
|
||||
LIMIT 2000
|
||||
""", _FEED_COMMIT_TYPES).fetchall()
|
||||
|
||||
events = []
|
||||
claim_activity = {} # slug -> {challenges, enriches, signals, first_seen}
|
||||
|
||||
for row in rows:
|
||||
slugs = _extract_claim_slugs(row["description"], row["branch"])
|
||||
candidate_slug = slugs[0] if slugs else ""
|
||||
event_type = _classify_event(
|
||||
row["branch"], row["description"], row["commit_type"],
|
||||
candidate_slug=candidate_slug,
|
||||
)
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
contributor = _normalize_contributor(row["submitted_by"], row["agent"])
|
||||
# Hide pipeline-attributed events (reweave/*, ingestion/*) from the
|
||||
# public activity feed. They're automation maintenance, not
|
||||
# contributions — the daemon re-knits the graph nightly and ingests
|
||||
# external sources. Internal diagnostics + CI math still see these
|
||||
# rows in prs / contribution_events; only the public timeline drops
|
||||
# them. Mirrors the existing _FEED_COMMIT_TYPES filter (which hides
|
||||
# commit_type='pipeline') along the contributor axis.
|
||||
if contributor == "pipeline":
|
||||
continue
|
||||
merged_at = row["merged_at"] or ""
|
||||
domain = row["domain"] or "unknown"
|
||||
kind = _KIND_MAP.get(event_type, event_type)
|
||||
|
||||
ci_map = {
|
||||
"create": 0.35, "enrich": 0.25, "challenge": 0.40,
|
||||
"source": 0.15, "session_digest": 0.05,
|
||||
}
|
||||
ci_earned = ci_map.get(event_type, 0)
|
||||
|
||||
# Source events never carry a claim_slug — no claim was written.
|
||||
# target_url points at the archived file on Forgejo instead.
|
||||
if event_type == "source":
|
||||
archive_slug = _archive_slug_from_branch(row["branch"])
|
||||
summary_text = _summary_from_branch(row["branch"])
|
||||
source_display_slug = (
|
||||
summary_text.lower().replace(" ", "-") or row["branch"]
|
||||
)
|
||||
events.append({
|
||||
"kind": kind,
|
||||
"type": "source",
|
||||
"target_url": _source_target_url(domain, archive_slug),
|
||||
"claim_slug": "",
|
||||
"source_slug": source_display_slug,
|
||||
"domain": domain,
|
||||
"contributor": contributor,
|
||||
"timestamp": merged_at,
|
||||
"ci_earned": round(ci_earned, 2),
|
||||
"summary": summary_text,
|
||||
"pr_number": row["number"],
|
||||
"pr_url": _pr_url(row["number"], row["github_pr"]),
|
||||
"source_channel": row["source_channel"] or "unknown",
|
||||
})
|
||||
continue
|
||||
|
||||
# Session digests have no clickthrough surface yet (per-agent
|
||||
# session pages not built). target_url=null so frontend renders
|
||||
# plain text instead of a broken /claims/research-... link.
|
||||
if event_type == "session_digest":
|
||||
summary_text = _summary_from_branch(row["branch"]) or "Research session"
|
||||
events.append({
|
||||
"kind": kind,
|
||||
"type": "session_digest",
|
||||
"target_url": None,
|
||||
"claim_slug": "",
|
||||
"domain": domain,
|
||||
"contributor": contributor,
|
||||
"timestamp": merged_at,
|
||||
"ci_earned": round(ci_earned, 2),
|
||||
"summary": summary_text,
|
||||
"pr_number": row["number"],
|
||||
"pr_url": _pr_url(row["number"], row["github_pr"]),
|
||||
"source_channel": row["source_channel"] or "unknown",
|
||||
})
|
||||
continue
|
||||
|
||||
for slug in slugs:
|
||||
if slug not in claim_activity:
|
||||
claim_activity[slug] = {
|
||||
"challenges": 0, "enriches": 0, "signals": 0,
|
||||
"first_seen": merged_at,
|
||||
}
|
||||
if event_type == "challenge":
|
||||
claim_activity[slug]["challenges"] += 1
|
||||
elif event_type == "enrich":
|
||||
claim_activity[slug]["enriches"] += 1
|
||||
else:
|
||||
claim_activity[slug]["signals"] += 1
|
||||
|
||||
summary_text = ""
|
||||
if row["description"]:
|
||||
first_title = row["description"].split("|")[0].strip()
|
||||
if len(first_title) > 120:
|
||||
first_title = first_title[:117] + "..."
|
||||
summary_text = first_title
|
||||
elif row["branch"]:
|
||||
summary_text = _summary_from_branch(row["branch"])
|
||||
|
||||
for slug in (slugs[:1] if slugs else [""]):
|
||||
events.append({
|
||||
"kind": kind,
|
||||
"type": event_type,
|
||||
"target_url": _claim_target_url(slug),
|
||||
"claim_slug": slug,
|
||||
"domain": domain,
|
||||
"contributor": contributor,
|
||||
"timestamp": merged_at,
|
||||
"ci_earned": round(ci_earned, 2),
|
||||
"summary": summary_text,
|
||||
"pr_number": row["number"],
|
||||
"pr_url": _pr_url(row["number"], row["github_pr"]),
|
||||
"source_channel": row["source_channel"] or "unknown",
|
||||
})
|
||||
|
||||
return events, claim_activity
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _sort_events(events, claim_activity, sort_mode, now_ts):
|
||||
if sort_mode == "recent":
|
||||
events.sort(key=lambda e: e["timestamp"], reverse=True)
|
||||
elif sort_mode == "hot":
|
||||
def hot_key(e):
|
||||
slug = e["claim_slug"]
|
||||
ca = claim_activity.get(slug, {"challenges": 0, "enriches": 0, "signals": 0})
|
||||
try:
|
||||
from datetime import datetime
|
||||
evt_time = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
|
||||
hours = (now_ts - evt_time.timestamp()) / 3600
|
||||
except (ValueError, AttributeError):
|
||||
hours = 9999
|
||||
return _hot_score(ca["challenges"], ca["enriches"], ca["signals"], hours)
|
||||
events.sort(key=hot_key, reverse=True)
|
||||
elif sort_mode == "important":
|
||||
type_rank = {
|
||||
"challenge": 0, "enrich": 1, "create": 2,
|
||||
"source": 3, "session_digest": 4,
|
||||
}
|
||||
events.sort(key=lambda e: (type_rank.get(e["type"], 5), -len(e["summary"])))
|
||||
return events
|
||||
|
||||
|
||||
async def handle_activity_feed(request):
|
||||
sort_mode = request.query.get("sort", "recent")
|
||||
if sort_mode not in ("hot", "recent", "important"):
|
||||
sort_mode = "recent"
|
||||
domain = request.query.get("domain", "")
|
||||
contributor = request.query.get("contributor", "")
|
||||
type_param = request.query.get("type", "")
|
||||
type_filter = {t.strip() for t in type_param.split(",") if t.strip()} if type_param else None
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", "20")), 100)
|
||||
except ValueError:
|
||||
limit = 20
|
||||
try:
|
||||
offset = max(int(request.query.get("offset", "0")), 0)
|
||||
except ValueError:
|
||||
offset = 0
|
||||
|
||||
now = time.time()
|
||||
if _cache["data"] is None or (now - _cache["ts"]) > CACHE_TTL:
|
||||
_cache["data"] = _build_events()
|
||||
_cache["ts"] = now
|
||||
|
||||
events, claim_activity = _cache["data"]
|
||||
|
||||
filtered = events
|
||||
if domain:
|
||||
filtered = [e for e in filtered if e["domain"] == domain]
|
||||
if contributor:
|
||||
filtered = [e for e in filtered if e["contributor"] == contributor]
|
||||
if type_filter:
|
||||
# Accept both legacy `type` values (create/enrich/challenge/source/
|
||||
# session_digest) and canonical `kind` values (claim_merged/etc.) so
|
||||
# callers can migrate at their own pace.
|
||||
filtered = [
|
||||
e for e in filtered
|
||||
if e["type"] in type_filter or e.get("kind") in type_filter
|
||||
]
|
||||
|
||||
sorted_events = _sort_events(list(filtered), claim_activity, sort_mode, now)
|
||||
total = len(sorted_events)
|
||||
page = sorted_events[offset:offset + limit]
|
||||
|
||||
return web.json_response({
|
||||
"events": page,
|
||||
"total": total,
|
||||
"sort": sort_mode,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}, headers={"Access-Control-Allow-Origin": "*"})
|
||||
|
||||
|
||||
def register(app):
|
||||
app.router.add_get("/api/activity-feed", handle_activity_feed)
|
||||
|
|
@ -1,539 +0,0 @@
|
|||
"""Argus active monitoring — health watchdog, quality regression, throughput anomaly detection.
|
||||
|
||||
Provides check functions that detect problems and return structured alerts.
|
||||
Called by /check endpoint (periodic cron) or on-demand.
|
||||
|
||||
Alert schema:
|
||||
{
|
||||
"id": str, # unique key for dedup (e.g. "dormant:ganymede")
|
||||
"severity": str, # "critical" | "warning" | "info"
|
||||
"category": str, # "health" | "quality" | "throughput" | "failure_pattern"
|
||||
"title": str, # human-readable headline
|
||||
"detail": str, # actionable description
|
||||
"agent": str|None, # affected agent (if applicable)
|
||||
"domain": str|None, # affected domain (if applicable)
|
||||
"detected_at": str, # ISO timestamp
|
||||
"auto_resolve": bool, # clears when condition clears
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import statistics
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# ─── Agent-domain mapping (static config, maintained by Argus) ──────────────
|
||||
|
||||
AGENT_DOMAINS = {
|
||||
"rio": ["internet-finance"],
|
||||
"clay": ["creative-industries"],
|
||||
"ganymede": None, # reviewer — cross-domain
|
||||
"epimetheus": None, # infra
|
||||
"leo": None, # standards
|
||||
"oberon": None, # evolution tracking
|
||||
"vida": None, # health monitoring
|
||||
"hermes": None, # comms
|
||||
"astra": None, # research
|
||||
}
|
||||
|
||||
# Thresholds
|
||||
DORMANCY_HOURS = 48
|
||||
APPROVAL_DROP_THRESHOLD = 15 # percentage points below 7-day baseline
|
||||
THROUGHPUT_DROP_RATIO = 0.5 # alert if today < 50% of 7-day SMA
|
||||
REJECTION_SPIKE_RATIO = 0.20 # single reason > 20% of recent rejections
|
||||
STUCK_LOOP_THRESHOLD = 3 # same agent + same rejection reason > N times in 6h
|
||||
COST_SPIKE_RATIO = 2.0 # daily cost > 2x 7-day average
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ─── Check: Agent Health (dormancy detection) ───────────────────────────────
|
||||
|
||||
|
||||
def check_agent_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Detect agents with no PR activity in the last DORMANCY_HOURS hours."""
|
||||
alerts = []
|
||||
|
||||
# Get last activity per agent
|
||||
rows = conn.execute(
|
||||
"""SELECT agent, MAX(last_attempt) as latest, COUNT(*) as total_prs
|
||||
FROM prs WHERE agent IS NOT NULL
|
||||
GROUP BY agent"""
|
||||
).fetchall()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for r in rows:
|
||||
agent = r["agent"]
|
||||
if agent in ("unknown", None):
|
||||
continue
|
||||
latest = r["latest"]
|
||||
if not latest:
|
||||
continue
|
||||
|
||||
last_dt = datetime.fromisoformat(latest)
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
hours_since = (now - last_dt).total_seconds() / 3600
|
||||
|
||||
if hours_since > DORMANCY_HOURS:
|
||||
alerts.append({
|
||||
"id": f"dormant:{agent}",
|
||||
"severity": "warning",
|
||||
"category": "health",
|
||||
"title": f"Agent '{agent}' dormant for {int(hours_since)}h",
|
||||
"detail": (
|
||||
f"No PR activity since {latest}. "
|
||||
f"Last seen {int(hours_since)}h ago (threshold: {DORMANCY_HOURS}h). "
|
||||
f"Total historical PRs: {r['total_prs']}."
|
||||
),
|
||||
"agent": agent,
|
||||
"domain": None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Check: Quality Regression (approval rate drop) ─────────────────────────
|
||||
|
||||
|
||||
def check_quality_regression(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Detect approval rate drops vs 7-day baseline, per agent and per domain."""
|
||||
alerts = []
|
||||
|
||||
# 7-day baseline approval rate (overall)
|
||||
baseline = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(CASE WHEN event='approved' THEN 1 END) as approved,
|
||||
COUNT(*) as total
|
||||
FROM audit_log
|
||||
WHERE stage='evaluate'
|
||||
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
||||
AND timestamp > datetime('now', '-7 days')"""
|
||||
).fetchone()
|
||||
baseline_rate = (baseline["approved"] / baseline["total"] * 100) if baseline["total"] else None
|
||||
|
||||
# 24h approval rate (overall)
|
||||
recent = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(CASE WHEN event='approved' THEN 1 END) as approved,
|
||||
COUNT(*) as total
|
||||
FROM audit_log
|
||||
WHERE stage='evaluate'
|
||||
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
||||
AND timestamp > datetime('now', '-24 hours')"""
|
||||
).fetchone()
|
||||
recent_rate = (recent["approved"] / recent["total"] * 100) if recent["total"] else None
|
||||
|
||||
if baseline_rate is not None and recent_rate is not None:
|
||||
drop = baseline_rate - recent_rate
|
||||
if drop > APPROVAL_DROP_THRESHOLD:
|
||||
alerts.append({
|
||||
"id": "quality_regression:overall",
|
||||
"severity": "critical",
|
||||
"category": "quality",
|
||||
"title": f"Approval rate dropped {drop:.0f}pp (24h: {recent_rate:.0f}% vs 7d: {baseline_rate:.0f}%)",
|
||||
"detail": (
|
||||
f"24h approval rate ({recent_rate:.1f}%) is {drop:.1f} percentage points below "
|
||||
f"7-day baseline ({baseline_rate:.1f}%). "
|
||||
f"Evaluated {recent['total']} PRs in last 24h."
|
||||
),
|
||||
"agent": None,
|
||||
"domain": None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
# Per-agent approval rate (24h vs 7d) — only for agents with >=5 evals in each window
|
||||
# COALESCE: rejection events use $.agent, eval events use $.domain_agent (Epimetheus 2026-03-28)
|
||||
_check_approval_by_dimension(conn, alerts, "agent", "COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent'))")
|
||||
|
||||
# Per-domain approval rate (24h vs 7d) — Theseus addition
|
||||
_check_approval_by_dimension(conn, alerts, "domain", "json_extract(detail, '$.domain')")
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
_ALLOWED_DIM_EXPRS = frozenset({
|
||||
"json_extract(detail, '$.agent')",
|
||||
"json_extract(detail, '$.domain')",
|
||||
"COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent'))",
|
||||
})
|
||||
|
||||
|
||||
def _check_approval_by_dimension(conn, alerts, dim_name, dim_expr):
|
||||
"""Check approval rate regression grouped by a dimension. dim_expr must be in _ALLOWED_DIM_EXPRS."""
|
||||
if dim_expr not in _ALLOWED_DIM_EXPRS:
|
||||
raise ValueError(f"untrusted dim_expr: {dim_expr}")
|
||||
# 7-day baseline per dimension
|
||||
baseline_rows = conn.execute(
|
||||
f"""SELECT {dim_expr} as dim_val,
|
||||
COUNT(CASE WHEN event='approved' THEN 1 END) as approved,
|
||||
COUNT(*) as total
|
||||
FROM audit_log
|
||||
WHERE stage='evaluate'
|
||||
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
||||
AND timestamp > datetime('now', '-7 days')
|
||||
AND {dim_expr} IS NOT NULL
|
||||
GROUP BY dim_val HAVING total >= 5"""
|
||||
).fetchall()
|
||||
baselines = {r["dim_val"]: (r["approved"] / r["total"] * 100) for r in baseline_rows}
|
||||
|
||||
# 24h per dimension
|
||||
recent_rows = conn.execute(
|
||||
f"""SELECT {dim_expr} as dim_val,
|
||||
COUNT(CASE WHEN event='approved' THEN 1 END) as approved,
|
||||
COUNT(*) as total
|
||||
FROM audit_log
|
||||
WHERE stage='evaluate'
|
||||
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
||||
AND timestamp > datetime('now', '-24 hours')
|
||||
AND {dim_expr} IS NOT NULL
|
||||
GROUP BY dim_val HAVING total >= 5"""
|
||||
).fetchall()
|
||||
|
||||
for r in recent_rows:
|
||||
val = r["dim_val"]
|
||||
if val not in baselines:
|
||||
continue
|
||||
recent_rate = r["approved"] / r["total"] * 100
|
||||
base_rate = baselines[val]
|
||||
drop = base_rate - recent_rate
|
||||
if drop > APPROVAL_DROP_THRESHOLD:
|
||||
alerts.append({
|
||||
"id": f"quality_regression:{dim_name}:{val}",
|
||||
"severity": "warning",
|
||||
"category": "quality",
|
||||
"title": f"{dim_name.title()} '{val}' approval dropped {drop:.0f}pp",
|
||||
"detail": (
|
||||
f"24h: {recent_rate:.1f}% vs 7d baseline: {base_rate:.1f}% "
|
||||
f"({r['total']} evals in 24h)."
|
||||
),
|
||||
"agent": val if dim_name == "agent" else None,
|
||||
"domain": val if dim_name == "domain" else None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
|
||||
# ─── Check: Throughput Anomaly ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def check_throughput(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Detect throughput stalling — today vs 7-day SMA."""
|
||||
alerts = []
|
||||
|
||||
# Daily merged counts for last 7 days
|
||||
rows = conn.execute(
|
||||
"""SELECT date(merged_at) as day, COUNT(*) as n
|
||||
FROM prs WHERE merged_at > datetime('now', '-7 days')
|
||||
GROUP BY day ORDER BY day"""
|
||||
).fetchall()
|
||||
|
||||
if len(rows) < 2:
|
||||
return alerts # Not enough data
|
||||
|
||||
daily_counts = [r["n"] for r in rows]
|
||||
sma = statistics.mean(daily_counts[:-1]) if len(daily_counts) > 1 else daily_counts[0]
|
||||
today_count = daily_counts[-1]
|
||||
|
||||
if sma > 0 and today_count < sma * THROUGHPUT_DROP_RATIO:
|
||||
alerts.append({
|
||||
"id": "throughput:stalling",
|
||||
"severity": "warning",
|
||||
"category": "throughput",
|
||||
"title": f"Throughput stalling: {today_count} merges today vs {sma:.0f}/day avg",
|
||||
"detail": (
|
||||
f"Today's merge count ({today_count}) is below {THROUGHPUT_DROP_RATIO:.0%} of "
|
||||
f"7-day average ({sma:.1f}/day). Daily counts: {daily_counts}."
|
||||
),
|
||||
"agent": None,
|
||||
"domain": None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Check: Rejection Reason Spike ─────────────────────────────────────────
|
||||
|
||||
|
||||
def check_rejection_spike(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Detect single rejection reason exceeding REJECTION_SPIKE_RATIO of recent rejections."""
|
||||
alerts = []
|
||||
|
||||
# Total rejected PRs in 24h (prs.eval_issues is the canonical source — Epimetheus 2026-04-02)
|
||||
total = conn.execute(
|
||||
"""SELECT COUNT(*) as n FROM prs
|
||||
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||
AND created_at > datetime('now', '-24 hours')"""
|
||||
).fetchone()["n"]
|
||||
|
||||
if total < 10:
|
||||
return alerts # Not enough data
|
||||
|
||||
# Count by rejection tag from prs.eval_issues
|
||||
tags = conn.execute(
|
||||
"""SELECT value as tag, COUNT(*) as cnt
|
||||
FROM prs, json_each(prs.eval_issues)
|
||||
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||
AND created_at > datetime('now', '-24 hours')
|
||||
GROUP BY tag ORDER BY cnt DESC"""
|
||||
).fetchall()
|
||||
|
||||
for t in tags:
|
||||
ratio = t["cnt"] / total
|
||||
if ratio > REJECTION_SPIKE_RATIO:
|
||||
alerts.append({
|
||||
"id": f"rejection_spike:{t['tag']}",
|
||||
"severity": "warning",
|
||||
"category": "quality",
|
||||
"title": f"Rejection reason '{t['tag']}' at {ratio:.0%} of rejections",
|
||||
"detail": (
|
||||
f"'{t['tag']}' accounts for {t['cnt']}/{total} rejections in 24h "
|
||||
f"({ratio:.1%}). Threshold: {REJECTION_SPIKE_RATIO:.0%}."
|
||||
),
|
||||
"agent": None,
|
||||
"domain": None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Check: Stuck Loops ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def check_stuck_loops(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Detect agents repeatedly failing on the same rejection reason."""
|
||||
alerts = []
|
||||
|
||||
# Agent + rejection reason from prs table directly (Epimetheus correction 2026-04-02)
|
||||
rows = conn.execute(
|
||||
"""SELECT agent, value as tag, COUNT(*) as cnt
|
||||
FROM prs, json_each(prs.eval_issues)
|
||||
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||
AND agent IS NOT NULL
|
||||
AND created_at > datetime('now', '-6 hours')
|
||||
GROUP BY agent, tag
|
||||
HAVING cnt > ?""",
|
||||
(STUCK_LOOP_THRESHOLD,),
|
||||
).fetchall()
|
||||
|
||||
for r in rows:
|
||||
alerts.append({
|
||||
"id": f"stuck_loop:{r['agent']}:{r['tag']}",
|
||||
"severity": "critical",
|
||||
"category": "health",
|
||||
"title": f"Agent '{r['agent']}' stuck: '{r['tag']}' failed {r['cnt']}x in 6h",
|
||||
"detail": (
|
||||
f"Agent '{r['agent']}' has been rejected for '{r['tag']}' "
|
||||
f"{r['cnt']} times in the last 6 hours (threshold: {STUCK_LOOP_THRESHOLD}). "
|
||||
f"Stop and reassess."
|
||||
),
|
||||
"agent": r["agent"],
|
||||
"domain": None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Check: Cost Spikes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def check_cost_spikes(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Detect daily cost exceeding 2x of 7-day average per agent."""
|
||||
alerts = []
|
||||
|
||||
# Check if costs table exists and has agent column
|
||||
try:
|
||||
cols = conn.execute("PRAGMA table_info(costs)").fetchall()
|
||||
col_names = {c["name"] for c in cols}
|
||||
except sqlite3.Error:
|
||||
return alerts
|
||||
|
||||
if "agent" not in col_names or "cost_usd" not in col_names:
|
||||
# Fall back to per-PR cost tracking
|
||||
rows = conn.execute(
|
||||
"""SELECT agent,
|
||||
SUM(CASE WHEN created_at > datetime('now', '-1 day') THEN cost_usd ELSE 0 END) as today_cost,
|
||||
SUM(CASE WHEN created_at > datetime('now', '-7 days') THEN cost_usd ELSE 0 END) / 7.0 as avg_daily
|
||||
FROM prs WHERE agent IS NOT NULL AND cost_usd > 0
|
||||
GROUP BY agent
|
||||
HAVING avg_daily > 0"""
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""SELECT agent,
|
||||
SUM(CASE WHEN timestamp > datetime('now', '-1 day') THEN cost_usd ELSE 0 END) as today_cost,
|
||||
SUM(CASE WHEN timestamp > datetime('now', '-7 days') THEN cost_usd ELSE 0 END) / 7.0 as avg_daily
|
||||
FROM costs WHERE agent IS NOT NULL
|
||||
GROUP BY agent
|
||||
HAVING avg_daily > 0"""
|
||||
).fetchall()
|
||||
|
||||
for r in rows:
|
||||
if r["avg_daily"] and r["today_cost"] > r["avg_daily"] * COST_SPIKE_RATIO:
|
||||
ratio = r["today_cost"] / r["avg_daily"]
|
||||
alerts.append({
|
||||
"id": f"cost_spike:{r['agent']}",
|
||||
"severity": "warning",
|
||||
"category": "health",
|
||||
"title": f"Agent '{r['agent']}' cost spike: ${r['today_cost']:.2f} today ({ratio:.1f}x avg)",
|
||||
"detail": (
|
||||
f"Today's cost (${r['today_cost']:.2f}) is {ratio:.1f}x the 7-day daily average "
|
||||
f"(${r['avg_daily']:.2f}). Threshold: {COST_SPIKE_RATIO}x."
|
||||
),
|
||||
"agent": r["agent"],
|
||||
"domain": None,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Check: Domain Rejection Patterns (Theseus addition) ───────────────────
|
||||
|
||||
|
||||
def check_domain_rejection_patterns(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Track rejection reason shift per domain — surfaces domain maturity issues."""
|
||||
alerts = []
|
||||
|
||||
# Per-domain rejection breakdown in 24h from prs table (Epimetheus correction 2026-04-02)
|
||||
rows = conn.execute(
|
||||
"""SELECT domain, value as tag, COUNT(*) as cnt
|
||||
FROM prs, json_each(prs.eval_issues)
|
||||
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||
AND domain IS NOT NULL
|
||||
AND created_at > datetime('now', '-24 hours')
|
||||
GROUP BY domain, tag
|
||||
ORDER BY domain, cnt DESC"""
|
||||
).fetchall()
|
||||
|
||||
# Group by domain
|
||||
domain_tags = {}
|
||||
for r in rows:
|
||||
d = r["domain"]
|
||||
if d not in domain_tags:
|
||||
domain_tags[d] = []
|
||||
domain_tags[d].append({"tag": r["tag"], "count": r["cnt"]})
|
||||
|
||||
# Flag if a domain has >50% of rejections from a single reason (concentrated failure)
|
||||
for domain, tags in domain_tags.items():
|
||||
total = sum(t["count"] for t in tags)
|
||||
if total < 5:
|
||||
continue
|
||||
top = tags[0]
|
||||
ratio = top["count"] / total
|
||||
if ratio > 0.5:
|
||||
alerts.append({
|
||||
"id": f"domain_rejection_pattern:{domain}:{top['tag']}",
|
||||
"severity": "info",
|
||||
"category": "failure_pattern",
|
||||
"title": f"Domain '{domain}': {ratio:.0%} of rejections are '{top['tag']}'",
|
||||
"detail": (
|
||||
f"In domain '{domain}', {top['count']}/{total} rejections (24h) are for "
|
||||
f"'{top['tag']}'. This may indicate a systematic issue with evidence standards "
|
||||
f"or schema compliance in this domain."
|
||||
),
|
||||
"agent": None,
|
||||
"domain": domain,
|
||||
"detected_at": _now_iso(),
|
||||
"auto_resolve": True,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Failure Report Generator ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_failure_report(conn: sqlite3.Connection, agent: str, hours: int = 24) -> dict | None:
|
||||
"""Compile a failure report for a specific agent.
|
||||
|
||||
Returns top rejection reasons, example PRs, and suggested fixes.
|
||||
Designed to be sent directly to the agent via Pentagon messaging.
|
||||
"""
|
||||
hours = int(hours) # defensive — callers should pass int, but enforce it
|
||||
rows = conn.execute(
|
||||
"""SELECT value as tag, COUNT(*) as cnt,
|
||||
GROUP_CONCAT(DISTINCT number) as pr_numbers
|
||||
FROM prs, json_each(prs.eval_issues)
|
||||
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
||||
AND agent = ?
|
||||
AND created_at > datetime('now', ? || ' hours')
|
||||
GROUP BY tag ORDER BY cnt DESC
|
||||
LIMIT 5""",
|
||||
(agent, f"-{hours}"),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
total_rejections = sum(r["cnt"] for r in rows)
|
||||
top_reasons = []
|
||||
for r in rows:
|
||||
prs = r["pr_numbers"].split(",")[:3] if r["pr_numbers"] else []
|
||||
top_reasons.append({
|
||||
"reason": r["tag"],
|
||||
"count": r["cnt"],
|
||||
"pct": round(r["cnt"] / total_rejections * 100, 1),
|
||||
"example_prs": prs,
|
||||
"suggestion": _suggest_fix(r["tag"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"agent": agent,
|
||||
"period_hours": hours,
|
||||
"total_rejections": total_rejections,
|
||||
"top_reasons": top_reasons,
|
||||
"generated_at": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
def _suggest_fix(rejection_tag: str) -> str:
|
||||
"""Map known rejection reasons to actionable suggestions."""
|
||||
suggestions = {
|
||||
"broken_wiki_links": "Check that all [[wiki links]] in claims resolve to existing files. Run link validation before submitting.",
|
||||
"near_duplicate": "Search existing claims before creating new ones. Use semantic search to find similar claims.",
|
||||
"frontmatter_schema": "Validate YAML frontmatter against the claim schema. Required fields: title, domain, confidence, type.",
|
||||
"weak_evidence": "Add concrete sources, data points, or citations. Claims need evidence that can be independently verified.",
|
||||
"missing_confidence": "Every claim needs a confidence level: proven, likely, experimental, or speculative.",
|
||||
"domain_mismatch": "Ensure claims are filed under the correct domain. Check domain definitions if unsure.",
|
||||
"too_broad": "Break broad claims into specific, testable sub-claims.",
|
||||
"missing_links": "Claims should link to related claims, entities, or sources. Isolated claims are harder to verify.",
|
||||
}
|
||||
return suggestions.get(rejection_tag, f"Review rejection reason '{rejection_tag}' and adjust extraction accordingly.")
|
||||
|
||||
|
||||
# ─── Run All Checks ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_all_checks(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""Execute all check functions and return combined alerts."""
|
||||
alerts = []
|
||||
alerts.extend(check_agent_health(conn))
|
||||
alerts.extend(check_quality_regression(conn))
|
||||
alerts.extend(check_throughput(conn))
|
||||
alerts.extend(check_rejection_spike(conn))
|
||||
alerts.extend(check_stuck_loops(conn))
|
||||
alerts.extend(check_cost_spikes(conn))
|
||||
alerts.extend(check_domain_rejection_patterns(conn))
|
||||
return alerts
|
||||
|
||||
|
||||
def format_alert_message(alert: dict) -> str:
|
||||
"""Format an alert for Pentagon messaging."""
|
||||
severity_icon = {"critical": "!!", "warning": "!", "info": "~"}
|
||||
icon = severity_icon.get(alert["severity"], "?")
|
||||
return f"[{icon}] {alert['title']}\n{alert['detail']}"
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
"""Route handlers for /check and /api/alerts endpoints.
|
||||
|
||||
Import into app.py and register routes in create_app().
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
from alerting import run_all_checks, generate_failure_report, format_alert_message # requires CWD = deploy dir; switch to relative import if packaged
|
||||
|
||||
logger = logging.getLogger("argus.alerting")
|
||||
|
||||
# In-memory alert store (replaced each /check cycle, persists between requests)
|
||||
_active_alerts: list[dict] = []
|
||||
_last_check: str | None = None
|
||||
|
||||
|
||||
async def handle_check(request):
|
||||
"""GET /check — run all monitoring checks, update active alerts, return results.
|
||||
|
||||
Designed to be called by systemd timer every 5 minutes.
|
||||
Returns JSON summary of all detected issues.
|
||||
"""
|
||||
conn = request.app["_alerting_conn_func"]()
|
||||
try:
|
||||
alerts = run_all_checks(conn)
|
||||
|
||||
# Generate failure reports for agents with stuck loops
|
||||
failure_reports = {}
|
||||
stuck_agents = {a["agent"] for a in alerts if a["category"] == "health" and "stuck" in a["id"] and a["agent"]}
|
||||
for agent in stuck_agents:
|
||||
report = generate_failure_report(conn, agent)
|
||||
if report:
|
||||
failure_reports[agent] = report
|
||||
except Exception as e:
|
||||
logger.error("Check failed: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
global _active_alerts, _last_check
|
||||
_active_alerts = alerts
|
||||
_last_check = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
result = {
|
||||
"checked_at": _last_check,
|
||||
"alert_count": len(alerts),
|
||||
"critical": sum(1 for a in alerts if a["severity"] == "critical"),
|
||||
"warning": sum(1 for a in alerts if a["severity"] == "warning"),
|
||||
"info": sum(1 for a in alerts if a["severity"] == "info"),
|
||||
"alerts": alerts,
|
||||
"failure_reports": failure_reports,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Check complete: %d alerts (%d critical, %d warning)",
|
||||
len(alerts),
|
||||
result["critical"],
|
||||
result["warning"],
|
||||
)
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
async def handle_api_alerts(request):
|
||||
"""GET /api/alerts — return current active alerts.
|
||||
|
||||
Query params:
|
||||
severity: filter by severity (critical, warning, info)
|
||||
category: filter by category (health, quality, throughput, failure_pattern)
|
||||
agent: filter by agent name
|
||||
domain: filter by domain
|
||||
"""
|
||||
alerts = list(_active_alerts)
|
||||
|
||||
# Filters
|
||||
severity = request.query.get("severity")
|
||||
if severity:
|
||||
alerts = [a for a in alerts if a["severity"] == severity]
|
||||
|
||||
category = request.query.get("category")
|
||||
if category:
|
||||
alerts = [a for a in alerts if a["category"] == category]
|
||||
|
||||
agent = request.query.get("agent")
|
||||
if agent:
|
||||
alerts = [a for a in alerts if a.get("agent") == agent]
|
||||
|
||||
domain = request.query.get("domain")
|
||||
if domain:
|
||||
alerts = [a for a in alerts if a.get("domain") == domain]
|
||||
|
||||
return web.json_response({
|
||||
"alerts": alerts,
|
||||
"total": len(alerts),
|
||||
"last_check": _last_check,
|
||||
})
|
||||
|
||||
|
||||
async def handle_api_failure_report(request):
|
||||
"""GET /api/failure-report/{agent} — generate failure report for an agent.
|
||||
|
||||
Query params:
|
||||
hours: lookback window (default 24)
|
||||
"""
|
||||
agent = request.match_info["agent"]
|
||||
try:
|
||||
hours = min(int(request.query.get("hours", "24")), 168)
|
||||
except ValueError:
|
||||
hours = 24
|
||||
conn = request.app["_alerting_conn_func"]()
|
||||
try:
|
||||
report = generate_failure_report(conn, agent, hours)
|
||||
finally:
|
||||
conn.close()
|
||||
if not report:
|
||||
return web.json_response({"agent": agent, "status": "no_rejections", "period_hours": hours})
|
||||
|
||||
return web.json_response(report)
|
||||
|
||||
|
||||
def register_alerting_routes(app, get_conn_func):
|
||||
"""Register alerting routes on the app.
|
||||
|
||||
get_conn_func: callable that returns a read-only sqlite3.Connection
|
||||
"""
|
||||
app["_alerting_conn_func"] = get_conn_func
|
||||
app.router.add_get("/check", handle_check)
|
||||
app.router.add_get("/api/alerts", handle_api_alerts)
|
||||
app.router.add_get("/api/failure-report/{agent}", handle_api_failure_report)
|
||||
1188
diagnostics/app.py
1188
diagnostics/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,143 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""One-time backfill: populate submitted_by on prs table from source archive files.
|
||||
|
||||
Matches PRs to sources via branch name slug → source filename.
|
||||
Reads proposed_by and intake_tier from source frontmatter.
|
||||
|
||||
Run: python3 backfill_submitted_by.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/opt/teleo-eval/pipeline/pipeline.db")
|
||||
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", "/opt/teleo-eval/workspaces/main/inbox/archive"))
|
||||
|
||||
|
||||
def parse_frontmatter(path: Path) -> dict:
|
||||
"""Parse YAML-like frontmatter from a markdown file."""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("---", 3)
|
||||
if end == -1:
|
||||
return {}
|
||||
fm = {}
|
||||
for line in text[3:end].strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, val = line.partition(":")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if val.lower() == "null" or val == "":
|
||||
val = None
|
||||
fm[key] = val
|
||||
return fm
|
||||
|
||||
|
||||
def slug_from_branch(branch: str) -> str:
|
||||
"""Extract source slug from branch name like 'extract/2026-04-06-slug-hash'."""
|
||||
if "/" in branch:
|
||||
branch = branch.split("/", 1)[1]
|
||||
# Strip trailing hex hash (e.g., -3e68, -a6af)
|
||||
branch = re.sub(r"-[0-9a-f]{4}$", "", branch)
|
||||
return branch
|
||||
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Build source index: filename stem → frontmatter
|
||||
source_index = {}
|
||||
if ARCHIVE_DIR.exists():
|
||||
for f in ARCHIVE_DIR.glob("*.md"):
|
||||
fm = parse_frontmatter(f)
|
||||
source_index[f.stem] = fm
|
||||
print(f"Indexed {len(source_index)} source files from {ARCHIVE_DIR}")
|
||||
|
||||
# Get all PRs without submitted_by
|
||||
prs = conn.execute(
|
||||
"SELECT number, branch FROM prs WHERE submitted_by IS NULL AND branch IS NOT NULL"
|
||||
).fetchall()
|
||||
print(f"Found {len(prs)} PRs without submitted_by")
|
||||
|
||||
updated = 0
|
||||
for pr in prs:
|
||||
branch = pr["branch"]
|
||||
slug = slug_from_branch(branch)
|
||||
|
||||
# Try to match slug to a source file
|
||||
fm = source_index.get(slug)
|
||||
if not fm:
|
||||
# Try partial matching: slug might be a substring of the source filename
|
||||
for stem, sfm in source_index.items():
|
||||
if slug in stem or stem in slug:
|
||||
fm = sfm
|
||||
break
|
||||
|
||||
# `submitted_by` is stored as a canonical handle (lowercase, no @, no
|
||||
# "(self-directed)" / "(reweave)" suffix). Read consumers normalize via
|
||||
# attribution.normalize_handle, so writing decorated strings produces
|
||||
# downstream 404s on /contributors/{handle} (livingip-web timeline).
|
||||
if fm:
|
||||
proposed_by = fm.get("proposed_by")
|
||||
intake_tier = fm.get("intake_tier")
|
||||
|
||||
if proposed_by:
|
||||
contributor = proposed_by.strip().strip('"').strip("'").lower().lstrip("@")
|
||||
elif intake_tier == "research-task":
|
||||
# Derive agent from branch prefix
|
||||
prefix = branch.split("/", 1)[0] if "/" in branch else "unknown"
|
||||
agent_map = {
|
||||
"extract": "pipeline", "ingestion": "pipeline",
|
||||
"rio": "rio", "theseus": "theseus", "vida": "vida",
|
||||
"clay": "clay", "astra": "astra", "leo": "leo",
|
||||
"reweave": "pipeline",
|
||||
}
|
||||
contributor = agent_map.get(prefix, prefix)
|
||||
elif intake_tier == "directed":
|
||||
contributor = "m3taversal"
|
||||
else:
|
||||
# Default: if source exists but no proposed_by, operator submitted it.
|
||||
contributor = "m3taversal"
|
||||
|
||||
if contributor:
|
||||
conn.execute(
|
||||
"UPDATE prs SET submitted_by = ?, source_path = ? WHERE number = ?",
|
||||
(contributor, f"inbox/archive/{slug}.md", pr["number"]),
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
# Agent-named branches from overnight research sessions
|
||||
if branch.startswith(("rio/", "theseus/", "vida/", "clay/", "astra/", "leo/")):
|
||||
agent = branch.split("/", 1)[0]
|
||||
conn.execute(
|
||||
"UPDATE prs SET submitted_by = ? WHERE number = ?",
|
||||
(agent, pr["number"]),
|
||||
)
|
||||
updated += 1
|
||||
elif branch.startswith("reweave/"):
|
||||
conn.execute(
|
||||
"UPDATE prs SET submitted_by = 'pipeline' WHERE number = ?",
|
||||
(pr["number"],),
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
# Everything else (extract/, ingestion/, unknown) → operator directed it
|
||||
conn.execute(
|
||||
"UPDATE prs SET submitted_by = 'm3taversal' WHERE number = ?",
|
||||
(pr["number"],),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Updated {updated}/{len(prs)} PRs with submitted_by")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,560 +0,0 @@
|
|||
"""Claims API — list endpoint + canonical claim detail page.
|
||||
|
||||
Owner: Argus
|
||||
Routes:
|
||||
GET /api/claims — list/filter (frontmatter scan, lightweight)
|
||||
GET /api/claims/{slug} — full claim detail (Ship contract)
|
||||
GET /api/domains — domain rollups for sidebar
|
||||
|
||||
The detail endpoint is the canonical /claims/{slug} backend per Ship's
|
||||
2026-04-29 brief. One round-trip, no N+1 cascade. Wikilinks resolved
|
||||
server-side via title→slug index built from a tree walk.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from aiohttp import web
|
||||
|
||||
# Codex tree roots — claims live in three places (Sourcer Apr 26 fix scope)
|
||||
CODEX_BASE = Path("/opt/teleo-eval/workspaces/main")
|
||||
CLAIM_TREES = [CODEX_BASE / "domains", CODEX_BASE / "foundations", CODEX_BASE / "core"]
|
||||
|
||||
# pipeline.db for joins (review_records, prs, sources)
|
||||
DB_PATH = "/opt/teleo-eval/pipeline/pipeline.db"
|
||||
|
||||
# In-process caches
|
||||
_list_cache = {"data": None, "ts": 0}
|
||||
_LIST_CACHE_TTL = 300 # 5 min — list view tolerates staleness
|
||||
|
||||
_index_cache = {"by_title": None, "by_stem": None, "ts": 0}
|
||||
_INDEX_CACHE_TTL = 60 # 1 min — title→slug index for wikilink resolution
|
||||
|
||||
CORS_HEADERS = {"Access-Control-Allow-Origin": "*"}
|
||||
|
||||
# Wikilink pattern. [[text]] or [[text|alias]] — we keep the link text only.
|
||||
_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+?)(?:[#|][^\]]*)?\]\]")
|
||||
|
||||
|
||||
# ─── Normalization ─────────────────────────────────────────────────────────
|
||||
|
||||
def _normalize_for_match(s):
|
||||
"""Collapse a title or slug to a comparable form.
|
||||
|
||||
Rules (from Ship's brief — match the link-fixer canonicalization):
|
||||
- lowercase
|
||||
- hyphen ↔ space tolerant (both → single space)
|
||||
- collapse runs of whitespace
|
||||
- strip leading/trailing whitespace
|
||||
- drop trailing punctuation that gets stripped from filenames
|
||||
(`.`, `?`, `!`, `:`, `--`)
|
||||
NOTE: lib/attribution.py exposes only normalize_handle today, not the
|
||||
title normalizer Ship referenced. Implementing inline; if a canonical
|
||||
helper lands later we point at it.
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
s = str(s).lower().strip()
|
||||
# Treat hyphens as spaces, then collapse whitespace runs
|
||||
s = s.replace("-", " ").replace("_", " ")
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
# Strip ASCII punctuation that filenames drop
|
||||
s = re.sub(r"[^\w\s]", "", s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
# ─── Frontmatter parse ─────────────────────────────────────────────────────
|
||||
|
||||
_CODE_FENCE_WRAPPER_RE = re.compile(r"^\s*```(?:markdown|md)?\s*\n(.*?)\n```\s*$", re.DOTALL)
|
||||
|
||||
|
||||
def _split_frontmatter(text):
|
||||
"""Return (frontmatter_dict, body_str) or (None, None) if not a claim file.
|
||||
|
||||
Tolerates files wrapped in a top-level ```markdown ... ``` code fence —
|
||||
some agents have produced these (e.g. Montreal Protocol claim from Astra,
|
||||
2024-12-09). Unwrap once before frontmatter detection.
|
||||
"""
|
||||
if not text:
|
||||
return None, None
|
||||
m = _CODE_FENCE_WRAPPER_RE.match(text)
|
||||
if m:
|
||||
text = m.group(1)
|
||||
text = text.lstrip()
|
||||
if not text.startswith("---"):
|
||||
return None, None
|
||||
try:
|
||||
end = text.index("\n---", 3)
|
||||
except ValueError:
|
||||
return None, None
|
||||
try:
|
||||
fm = yaml.safe_load(text[3:end])
|
||||
except Exception:
|
||||
return None, None
|
||||
if not isinstance(fm, dict):
|
||||
return None, None
|
||||
body = text[end + 4:].lstrip()
|
||||
return fm, body
|
||||
|
||||
|
||||
def _read_claim_file(filepath):
|
||||
"""Read a claim file from disk. Returns (frontmatter, body) or (None, None)."""
|
||||
try:
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None, None
|
||||
return _split_frontmatter(text)
|
||||
|
||||
|
||||
# ─── Tree walk + indexing ──────────────────────────────────────────────────
|
||||
|
||||
def _walk_claim_files():
|
||||
"""Yield Path objects for every .md claim file in domains/, foundations/, core/."""
|
||||
for root in CLAIM_TREES:
|
||||
if not root.exists():
|
||||
continue
|
||||
for f in root.rglob("*.md"):
|
||||
if f.name == "_map.md":
|
||||
continue
|
||||
yield f
|
||||
|
||||
|
||||
def _build_indexes():
|
||||
"""Build (title→stem, stem→relpath) indexes for wikilink resolution.
|
||||
|
||||
Cached for _INDEX_CACHE_TTL. Pulls from claim-index endpoint when
|
||||
possible (already cached upstream) and falls back to filesystem walk.
|
||||
"""
|
||||
now = time.time()
|
||||
if _index_cache["by_title"] is not None and now - _index_cache["ts"] < _INDEX_CACHE_TTL:
|
||||
return _index_cache["by_title"], _index_cache["by_stem"]
|
||||
|
||||
by_title = {}
|
||||
by_stem = {}
|
||||
for f in _walk_claim_files():
|
||||
stem = f.stem
|
||||
rel = str(f.relative_to(CODEX_BASE))
|
||||
by_stem[stem] = rel
|
||||
# Index by stem-as-normalized too (covers wikilinks that use the slug)
|
||||
by_title[_normalize_for_match(stem)] = stem
|
||||
# Also try parsing the title from frontmatter for higher-fidelity matches
|
||||
fm, _ = _read_claim_file(f)
|
||||
if fm:
|
||||
title = fm.get("title")
|
||||
if title:
|
||||
key = _normalize_for_match(title)
|
||||
if key and key not in by_title:
|
||||
by_title[key] = stem
|
||||
|
||||
_index_cache["by_title"] = by_title
|
||||
_index_cache["by_stem"] = by_stem
|
||||
_index_cache["ts"] = now
|
||||
return by_title, by_stem
|
||||
|
||||
|
||||
def _resolve_wikilinks(body, by_title):
|
||||
"""Extract [[link]] occurrences from body, return {link_text: slug_or_null}."""
|
||||
out = {}
|
||||
for match in _WIKILINK_RE.finditer(body or ""):
|
||||
link_text = match.group(1).strip()
|
||||
if not link_text or link_text in out:
|
||||
continue
|
||||
norm = _normalize_for_match(link_text)
|
||||
out[link_text] = by_title.get(norm)
|
||||
return out
|
||||
|
||||
|
||||
# ─── Edge extraction from frontmatter ──────────────────────────────────────
|
||||
|
||||
_EDGE_FIELDS = {
|
||||
"supports": "supports",
|
||||
"challenges": "challenges",
|
||||
"challenged_by": "challenges", # canonical: store as challenges direction
|
||||
"related": "related",
|
||||
"related_claims": "related",
|
||||
"depends_on": "depends_on",
|
||||
}
|
||||
|
||||
|
||||
def _extract_edges(fm, by_title, by_stem):
|
||||
"""Return edges dict shaped per Ship's contract.
|
||||
|
||||
Each edge is {slug, title, exists}. Slug resolved through title index.
|
||||
"""
|
||||
edges = {"supports": [], "challenges": [], "related": [], "depends_on": []}
|
||||
|
||||
for fm_key, edge_kind in _EDGE_FIELDS.items():
|
||||
raw = fm.get(fm_key)
|
||||
if not raw:
|
||||
continue
|
||||
items = raw if isinstance(raw, list) else [raw]
|
||||
for item in items:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
text = item.strip()
|
||||
# Strip wikilink wrapping if present
|
||||
text = re.sub(r"^\[\[|\]\]$", "", text)
|
||||
# Strip pipe annotations: "[[link|alias]]" style or "claim | edge_type | date"
|
||||
text = text.split("|")[0].strip()
|
||||
if not text:
|
||||
continue
|
||||
# Try title match first, fall back to stem match
|
||||
slug = by_title.get(_normalize_for_match(text))
|
||||
if not slug and text in by_stem:
|
||||
slug = text
|
||||
edges[edge_kind].append({
|
||||
"slug": slug,
|
||||
"title": text,
|
||||
"exists": slug is not None,
|
||||
})
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
# ─── Source provenance ─────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_sourced_from(conn, claim_filepath, fm, title, stem):
|
||||
"""Build sourced_from list for the claim.
|
||||
|
||||
Strategy: find PRs that produced this claim (via prs.description LIKE
|
||||
or branch slug match), look at prs.source_path → inbox archive file →
|
||||
parse that source's frontmatter for title/url. Falls back to the raw
|
||||
`source` string from the claim's own frontmatter.
|
||||
|
||||
Both `title` and `stem` must be non-empty — caller (handler) already
|
||||
falls back stem→title; passing empty values would leak `LIKE '%%'`
|
||||
and match unrelated PRs.
|
||||
"""
|
||||
out = []
|
||||
seen_paths = set()
|
||||
pr_rows = []
|
||||
if (title or "").strip() and (stem or "").strip():
|
||||
try:
|
||||
pr_rows = conn.execute(
|
||||
"""SELECT DISTINCT source_path
|
||||
FROM prs
|
||||
WHERE source_path IS NOT NULL AND source_path != ''
|
||||
AND (description LIKE ? OR branch LIKE ?)
|
||||
LIMIT 10""",
|
||||
(f"%{title}%", f"%{stem}%"),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
pr_rows = []
|
||||
|
||||
for row in pr_rows:
|
||||
path = row["source_path"]
|
||||
if not path or path in seen_paths:
|
||||
continue
|
||||
seen_paths.add(path)
|
||||
out.append(_resolve_source_file(path))
|
||||
|
||||
# 2. Fallback: parse raw source frontmatter field if no PR match
|
||||
if not out:
|
||||
raw = fm.get("source")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
out.append({"path": None, "title": raw.strip()[:200], "url": None})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_source_file(rel_path):
|
||||
"""Given inbox/archive/... path, parse frontmatter for title+url. Best-effort."""
|
||||
full = CODEX_BASE / rel_path
|
||||
entry = {"path": rel_path, "title": None, "url": None}
|
||||
if full.exists():
|
||||
fm, _ = _read_claim_file(full)
|
||||
if fm:
|
||||
entry["title"] = fm.get("title") or fm.get("source") or rel_path
|
||||
entry["url"] = fm.get("url")
|
||||
if not entry["title"]:
|
||||
# Last resort: derive from filename
|
||||
entry["title"] = Path(rel_path).stem.replace("-", " ")
|
||||
return entry
|
||||
|
||||
|
||||
# ─── Reviews + PRs ─────────────────────────────────────────────────────────
|
||||
|
||||
def _load_pr_history(conn, title, stem):
|
||||
"""Find PRs that touched this claim and their reviews.
|
||||
|
||||
Both title and stem must be non-empty strings — empty leaks `LIKE '%%'`
|
||||
which matches every PR. Handler already populates a fallback so this
|
||||
is a defense-in-depth guard.
|
||||
"""
|
||||
if not (title or "").strip() or not (stem or "").strip():
|
||||
return [], []
|
||||
|
||||
try:
|
||||
pr_rows = conn.execute(
|
||||
"""SELECT number, merged_at, commit_type, agent, branch, status
|
||||
FROM prs
|
||||
WHERE merged_at IS NOT NULL
|
||||
AND (description LIKE ? OR branch LIKE ?)
|
||||
ORDER BY merged_at ASC
|
||||
LIMIT 50""",
|
||||
(f"%{title}%", f"%{stem}%"),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return [], []
|
||||
|
||||
prs = [
|
||||
{
|
||||
"number": r["number"],
|
||||
"merged_at": r["merged_at"],
|
||||
"kind": r["commit_type"] or "unknown",
|
||||
"agent": r["agent"],
|
||||
"branch": r["branch"],
|
||||
}
|
||||
for r in pr_rows
|
||||
]
|
||||
|
||||
pr_numbers = [p["number"] for p in prs]
|
||||
if not pr_numbers:
|
||||
return prs, []
|
||||
|
||||
placeholders = ",".join("?" * len(pr_numbers))
|
||||
try:
|
||||
review_rows = conn.execute(
|
||||
f"""SELECT pr_number, reviewer, reviewer_model, outcome,
|
||||
rejection_reason, notes, reviewed_at
|
||||
FROM review_records
|
||||
WHERE pr_number IN ({placeholders})
|
||||
ORDER BY reviewed_at ASC""",
|
||||
pr_numbers,
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
review_rows = []
|
||||
|
||||
reviews = [
|
||||
{
|
||||
"pr_number": r["pr_number"],
|
||||
"reviewer": r["reviewer"],
|
||||
"model": r["reviewer_model"],
|
||||
"outcome": r["outcome"],
|
||||
"rejection_reason": r["rejection_reason"],
|
||||
"notes": r["notes"],
|
||||
"reviewed_at": r["reviewed_at"],
|
||||
}
|
||||
for r in review_rows
|
||||
]
|
||||
return prs, reviews
|
||||
|
||||
|
||||
# ─── List view (preserved) ─────────────────────────────────────────────────
|
||||
|
||||
def _parse_list_entry(filepath):
|
||||
fm, body = _read_claim_file(filepath)
|
||||
if not fm or fm.get("type") != "claim":
|
||||
return None
|
||||
links = _WIKILINK_RE.findall(body or "")
|
||||
paragraphs = [p.strip() for p in (body or "").split("\n\n")
|
||||
if p.strip() and not p.strip().startswith("#")]
|
||||
summary = paragraphs[0][:300] if paragraphs else ""
|
||||
return {
|
||||
"slug": filepath.stem,
|
||||
"title": fm.get("title", filepath.stem.replace("-", " ")),
|
||||
"domain": fm.get("domain", "unknown"),
|
||||
"confidence": fm.get("confidence", "unknown"),
|
||||
"agent": fm.get("agent"),
|
||||
"scope": fm.get("scope"),
|
||||
"created": str(fm.get("created", "")),
|
||||
"source": fm.get("source", "") if isinstance(fm.get("source"), str) else "",
|
||||
"sourcer": fm.get("sourcer", ""),
|
||||
"wiki_link_count": len(links),
|
||||
"summary": summary,
|
||||
"challenged_by": fm.get("challenged_by"),
|
||||
"related_claims": fm.get("related_claims", []),
|
||||
}
|
||||
|
||||
|
||||
def _load_all_claims_list():
|
||||
now = time.time()
|
||||
if _list_cache["data"] and now - _list_cache["ts"] < _LIST_CACHE_TTL:
|
||||
return _list_cache["data"]
|
||||
claims = []
|
||||
for f in _walk_claim_files():
|
||||
entry = _parse_list_entry(f)
|
||||
if entry:
|
||||
claims.append(entry)
|
||||
_list_cache["data"] = claims
|
||||
_list_cache["ts"] = now
|
||||
return claims
|
||||
|
||||
|
||||
# ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def handle_claims(request):
|
||||
claims = _load_all_claims_list()
|
||||
|
||||
domain = request.query.get("domain")
|
||||
search = request.query.get("q", "").lower()
|
||||
confidence = request.query.get("confidence")
|
||||
agent = request.query.get("agent")
|
||||
sort = request.query.get("sort", "recent")
|
||||
|
||||
filtered = claims
|
||||
if domain:
|
||||
filtered = [c for c in filtered if c["domain"] == domain]
|
||||
if confidence:
|
||||
filtered = [c for c in filtered if c["confidence"] == confidence]
|
||||
if agent:
|
||||
filtered = [c for c in filtered if c["agent"] == agent]
|
||||
if search:
|
||||
filtered = [c for c in filtered
|
||||
if search in c["title"].lower() or search in c["summary"].lower()]
|
||||
|
||||
if sort == "recent":
|
||||
filtered.sort(key=lambda c: c["created"], reverse=True)
|
||||
elif sort == "alpha":
|
||||
filtered.sort(key=lambda c: c["title"].lower())
|
||||
elif sort == "domain":
|
||||
filtered.sort(key=lambda c: (c["domain"], c["title"].lower()))
|
||||
|
||||
limit = min(int(request.query.get("limit", "50")), 200)
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
page = filtered[offset:offset + limit]
|
||||
|
||||
domain_counts = {}
|
||||
for c in claims:
|
||||
domain_counts[c["domain"]] = domain_counts.get(c["domain"], 0) + 1
|
||||
|
||||
return web.json_response({
|
||||
"claims": page,
|
||||
"total": len(filtered),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"domains": dict(sorted(domain_counts.items(), key=lambda x: -x[1])),
|
||||
"confidence_levels": sorted(set(c["confidence"] for c in claims)),
|
||||
"agents": sorted(set(c["agent"] for c in claims if c["agent"])),
|
||||
}, headers=CORS_HEADERS)
|
||||
|
||||
|
||||
async def handle_claim_detail(request):
|
||||
"""GET /api/claims/{slug} — canonical claim detail page (Ship contract).
|
||||
|
||||
One round-trip, all data resolved server-side. Wikilinks pre-resolved.
|
||||
"""
|
||||
requested_slug = request.match_info["slug"]
|
||||
by_title, by_stem = _build_indexes()
|
||||
|
||||
# Resolution order: exact stem → title-normalized (handles description-derived
|
||||
# slugs from /api/activity-feed that are longer than on-disk file stems) →
|
||||
# stem-as-prefix (handles description-derived slugs that are shorter than the
|
||||
# file stem because the description was truncated upstream).
|
||||
slug = requested_slug
|
||||
rel_path = by_stem.get(slug)
|
||||
if not rel_path:
|
||||
# Title fallback: requested slug = slugified frontmatter title
|
||||
norm = _normalize_for_match(requested_slug)
|
||||
resolved_stem = by_title.get(norm)
|
||||
if resolved_stem:
|
||||
slug = resolved_stem
|
||||
rel_path = by_stem.get(resolved_stem)
|
||||
if not rel_path:
|
||||
# Prefix fallback: walk stems sharing a common prefix with the request,
|
||||
# pick longest match. Anchored at 32 chars to avoid spurious hits.
|
||||
norm_req = _normalize_for_match(requested_slug)
|
||||
best_stem = None
|
||||
best_len = 0
|
||||
for stem in by_stem:
|
||||
norm_stem = _normalize_for_match(stem)
|
||||
common = 0
|
||||
for a, b in zip(norm_req, norm_stem):
|
||||
if a != b:
|
||||
break
|
||||
common += 1
|
||||
if common >= 32 and common > best_len:
|
||||
best_stem = stem
|
||||
best_len = common
|
||||
if best_stem:
|
||||
slug = best_stem
|
||||
rel_path = by_stem.get(best_stem)
|
||||
if not rel_path:
|
||||
return web.json_response({"error": "claim not found", "slug": requested_slug},
|
||||
status=404, headers=CORS_HEADERS)
|
||||
|
||||
filepath = CODEX_BASE / rel_path
|
||||
fm, body = _read_claim_file(filepath)
|
||||
if not fm:
|
||||
# File exists at this stem but has no parseable frontmatter — almost
|
||||
# always a stray enrichment fragment that landed in domains/ without
|
||||
# being merged into a parent claim. Surfacing as 404 (no claim here)
|
||||
# not 500: the caller can't act on it differently anyway.
|
||||
return web.json_response({"error": "claim not found", "slug": slug,
|
||||
"reason": "file_no_frontmatter"},
|
||||
status=404, headers=CORS_HEADERS)
|
||||
|
||||
# Open read-only DB connection for this request
|
||||
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
title = fm.get("title") or slug.replace("-", " ")
|
||||
prs, reviews = _load_pr_history(conn, title, slug)
|
||||
sourced_from = _resolve_sourced_from(conn, filepath, fm, title, slug)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
last_review = None
|
||||
if reviews:
|
||||
latest = reviews[-1]
|
||||
last_review = {
|
||||
"outcome": latest["outcome"],
|
||||
"reviewer": latest["reviewer"],
|
||||
"date": (latest["reviewed_at"] or "")[:10],
|
||||
}
|
||||
|
||||
# secondary_domains: explicit list, or empty
|
||||
secondary = fm.get("secondary_domains") or fm.get("cross_domain_links") or []
|
||||
if isinstance(secondary, str):
|
||||
secondary = [secondary]
|
||||
|
||||
description = fm.get("description") or ""
|
||||
|
||||
edges = _extract_edges(fm, by_title, by_stem)
|
||||
wikilinks = _resolve_wikilinks(body, by_title)
|
||||
|
||||
response = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"domain": fm.get("domain", "unknown"),
|
||||
"secondary_domains": secondary,
|
||||
"confidence": fm.get("confidence", "unknown"),
|
||||
"description": description,
|
||||
"created": str(fm.get("created", "")),
|
||||
"last_review": last_review,
|
||||
"body": body or "",
|
||||
"sourced_from": sourced_from,
|
||||
"reviews": reviews,
|
||||
"prs": prs,
|
||||
"edges": edges,
|
||||
"wikilinks": wikilinks,
|
||||
}
|
||||
return web.json_response(response, headers=CORS_HEADERS)
|
||||
|
||||
|
||||
async def handle_domains(request):
|
||||
claims = _load_all_claims_list()
|
||||
domains = {}
|
||||
for c in claims:
|
||||
d = c["domain"]
|
||||
if d not in domains:
|
||||
domains[d] = {"name": d, "count": 0, "agents": set(), "confidence_dist": {}}
|
||||
domains[d]["count"] += 1
|
||||
if c["agent"]:
|
||||
domains[d]["agents"].add(c["agent"])
|
||||
conf = c["confidence"]
|
||||
domains[d]["confidence_dist"][conf] = domains[d]["confidence_dist"].get(conf, 0) + 1
|
||||
result = []
|
||||
for d in sorted(domains.values(), key=lambda x: -x["count"]):
|
||||
d["agents"] = sorted(d["agents"])
|
||||
result.append(d)
|
||||
return web.json_response(result, headers=CORS_HEADERS)
|
||||
|
||||
|
||||
def register_claims_routes(app):
|
||||
app.router.add_get("/api/claims", handle_claims)
|
||||
app.router.add_get("/api/claims/{slug}", handle_claim_detail)
|
||||
app.router.add_get("/api/domains", handle_domains)
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
"""Contributor profile API — GET /api/contributors/{handle}"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = os.environ.get("PIPELINE_DB", "/opt/teleo-eval/pipeline/pipeline.db")
|
||||
SYSTEM_ACCOUNTS = {"pipeline", "unknown", "teleo-agents", "teleo pipeline"}
|
||||
CODEX_PATH = "/opt/teleo-eval/workspaces/main"
|
||||
|
||||
CI_WEIGHTS = {
|
||||
"sourcer": 0.15,
|
||||
"extractor": 0.05,
|
||||
"challenger": 0.35,
|
||||
"synthesizer": 0.25,
|
||||
"reviewer": 0.20,
|
||||
}
|
||||
|
||||
FOUNDING_CUTOFF = "2026-03-15"
|
||||
|
||||
BADGE_DEFS = {
|
||||
"FOUNDING CONTRIBUTOR": {"rarity": "limited", "desc": "Contributed during pre-launch phase"},
|
||||
"BELIEF MOVER": {"rarity": "rare", "desc": "Challenge that led to a claim revision"},
|
||||
"KNOWLEDGE SOURCER": {"rarity": "uncommon", "desc": "Source that generated 3+ claims"},
|
||||
"DOMAIN SPECIALIST": {"rarity": "rare", "desc": "Top 3 CI contributor in a domain"},
|
||||
"VETERAN": {"rarity": "uncommon", "desc": "10+ accepted contributions"},
|
||||
"FIRST BLOOD": {"rarity": "common", "desc": "First contribution of any kind"},
|
||||
"CONTRIBUTOR": {"rarity": "common", "desc": "Account created + first accepted contribution"},
|
||||
}
|
||||
|
||||
|
||||
def _get_conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _compute_ci(row):
|
||||
total = 0
|
||||
for role, weight in CI_WEIGHTS.items():
|
||||
total += (row.get(f"{role}_count", 0) or 0) * weight
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def _compute_badges(handle, row, domain_breakdown, conn):
|
||||
badges = []
|
||||
first = row.get("first_contribution", "")
|
||||
|
||||
if first and first <= FOUNDING_CUTOFF:
|
||||
badges.append("FOUNDING CONTRIBUTOR")
|
||||
|
||||
claims = row.get("claims_merged", 0) or 0
|
||||
if claims > 0:
|
||||
badges.append("CONTRIBUTOR")
|
||||
badges.append("FIRST BLOOD")
|
||||
|
||||
if claims >= 10:
|
||||
badges.append("VETERAN")
|
||||
|
||||
challenger = row.get("challenger_count", 0) or 0
|
||||
challenge_ci = row.get("_challenge_count_from_scores", 0)
|
||||
if challenger > 0 or challenge_ci > 0:
|
||||
badges.append("BELIEF MOVER")
|
||||
|
||||
sourcer = row.get("sourcer_count", 0) or 0
|
||||
if sourcer >= 3:
|
||||
badges.append("KNOWLEDGE SOURCER")
|
||||
|
||||
return badges
|
||||
|
||||
|
||||
def _get_domain_breakdown(handle, conn):
|
||||
rows = conn.execute("""
|
||||
SELECT domain, COUNT(*) as cnt
|
||||
FROM prs
|
||||
WHERE status='merged' AND (LOWER(agent)=LOWER(?) OR LOWER(submitted_by)=LOWER(?))
|
||||
AND domain IS NOT NULL
|
||||
GROUP BY domain ORDER BY cnt DESC
|
||||
""", (handle, handle)).fetchall()
|
||||
return {r["domain"]: r["cnt"] for r in rows}
|
||||
|
||||
|
||||
def _get_contribution_timeline(handle, conn, limit=20):
|
||||
rows = conn.execute("""
|
||||
SELECT number, domain, status, created_at, description, commit_type, source_path
|
||||
FROM prs
|
||||
WHERE status='merged' AND (LOWER(agent)=LOWER(?) OR LOWER(submitted_by)=LOWER(?))
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
""", (handle, handle, limit)).fetchall()
|
||||
|
||||
timeline = []
|
||||
for r in rows:
|
||||
desc = r["description"] or ""
|
||||
if not desc and r["source_path"]:
|
||||
desc = os.path.basename(r["source_path"]).replace("-", " ").replace(".md", "")
|
||||
timeline.append({
|
||||
"pr_number": r["number"],
|
||||
"domain": r["domain"],
|
||||
"date": r["created_at"][:10] if r["created_at"] else None,
|
||||
"type": _classify_commit(r["commit_type"]),
|
||||
"summary": desc[:200] if desc else None,
|
||||
})
|
||||
return timeline
|
||||
|
||||
|
||||
def _classify_commit(commit_type):
|
||||
if not commit_type:
|
||||
return "create"
|
||||
ct = commit_type.lower()
|
||||
if "challenge" in ct:
|
||||
return "challenge"
|
||||
if "enrich" in ct or "update" in ct or "reweave" in ct:
|
||||
return "enrich"
|
||||
return "create"
|
||||
|
||||
|
||||
def _get_review_stats(handle, conn):
|
||||
rows = conn.execute("""
|
||||
SELECT outcome, COUNT(*) as cnt
|
||||
FROM review_records
|
||||
WHERE LOWER(agent) = LOWER(?)
|
||||
GROUP BY outcome
|
||||
""", (handle,)).fetchall()
|
||||
stats = {}
|
||||
for r in rows:
|
||||
stats[r["outcome"]] = r["cnt"]
|
||||
return stats
|
||||
|
||||
|
||||
def _get_action_ci(handle, conn):
|
||||
"""Get action-type CI from contribution_scores table.
|
||||
|
||||
Checks both exact handle and common variants (with/without suffix).
|
||||
"""
|
||||
h = handle.lower()
|
||||
base = re.sub(r"[-_]\w+\d+$", "", h)
|
||||
variants = list({h, base}) if base and base != h else [h]
|
||||
try:
|
||||
placeholders = ",".join("?" for _ in variants)
|
||||
rows = conn.execute(f"""
|
||||
SELECT event_type, SUM(ci_earned) as total, COUNT(*) as cnt
|
||||
FROM contribution_scores
|
||||
WHERE LOWER(contributor) IN ({placeholders})
|
||||
GROUP BY event_type
|
||||
""", variants).fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
breakdown = {}
|
||||
total = 0.0
|
||||
for r in rows:
|
||||
breakdown[r["event_type"]] = {
|
||||
"count": r["cnt"],
|
||||
"ci": round(r["total"], 4),
|
||||
}
|
||||
total += r["total"]
|
||||
|
||||
return {
|
||||
"total": round(total, 4),
|
||||
"breakdown": breakdown,
|
||||
}
|
||||
|
||||
|
||||
def _get_git_contributor(handle):
|
||||
"""Fallback: check git log for contributors not in pipeline.db."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--all", "--format=%H|%an|%ae|%aI", "--diff-filter=A", "--", "domains/"],
|
||||
capture_output=True, text=True, cwd=CODEX_PATH, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
claims = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
sha, name, email, date = parts
|
||||
if handle.lower() in name.lower() or handle.lower() in email.lower():
|
||||
claims.append({"sha": sha, "author": name, "email": email, "date": date[:10]})
|
||||
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
return {
|
||||
"handle": handle,
|
||||
"display_name": claims[0]["author"],
|
||||
"email": claims[0]["email"],
|
||||
"first_contribution": min(c["date"] for c in claims),
|
||||
"last_contribution": max(c["date"] for c in claims),
|
||||
"claims_merged": len(claims),
|
||||
"sourcer_count": 0,
|
||||
"extractor_count": 0,
|
||||
"challenger_count": 0,
|
||||
"synthesizer_count": 0,
|
||||
"reviewer_count": 0,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_contributor_profile(handle):
|
||||
conn = _get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM contributors WHERE LOWER(handle) = LOWER(?)", (handle,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
data = dict(row)
|
||||
else:
|
||||
git_data = _get_git_contributor(handle)
|
||||
if git_data:
|
||||
data = git_data
|
||||
else:
|
||||
return None
|
||||
|
||||
ci_score = _compute_ci(data)
|
||||
action_ci = _get_action_ci(handle, conn)
|
||||
domain_breakdown = _get_domain_breakdown(handle, conn)
|
||||
timeline = _get_contribution_timeline(handle, conn)
|
||||
review_stats = _get_review_stats(handle, conn)
|
||||
if action_ci and "challenge" in action_ci.get("breakdown", {}):
|
||||
data["_challenge_count_from_scores"] = action_ci["breakdown"]["challenge"]["count"]
|
||||
badges = _compute_badges(handle, data, domain_breakdown, conn)
|
||||
|
||||
# For git-only contributors, build domain breakdown from git
|
||||
if not domain_breakdown and not row:
|
||||
domain_breakdown = _git_domain_breakdown(handle)
|
||||
|
||||
hero_badge = None
|
||||
rarity_order = ["limited", "rare", "uncommon", "common"]
|
||||
for rarity in rarity_order:
|
||||
for b in badges:
|
||||
if BADGE_DEFS.get(b, {}).get("rarity") == rarity:
|
||||
hero_badge = b
|
||||
break
|
||||
if hero_badge:
|
||||
break
|
||||
|
||||
role_breakdown = {
|
||||
"sourcer": data.get("sourcer_count", 0) or 0,
|
||||
"extractor": data.get("extractor_count", 0) or 0,
|
||||
"challenger": data.get("challenger_count", 0) or 0,
|
||||
"synthesizer": data.get("synthesizer_count", 0) or 0,
|
||||
"reviewer": data.get("reviewer_count", 0) or 0,
|
||||
}
|
||||
total_roles = sum(role_breakdown.values())
|
||||
role_pct = {}
|
||||
for k, v in role_breakdown.items():
|
||||
role_pct[k] = round(v / total_roles * 100) if total_roles > 0 else 0
|
||||
|
||||
return {
|
||||
"handle": data.get("handle", handle),
|
||||
"display_name": data.get("display_name"),
|
||||
"ci_score": ci_score,
|
||||
"action_ci": action_ci,
|
||||
"primary_ci": action_ci["total"] if action_ci else ci_score,
|
||||
"hero_badge": hero_badge,
|
||||
"badges": [{"name": b, **BADGE_DEFS.get(b, {})} for b in badges],
|
||||
"joined": data.get("first_contribution"),
|
||||
"last_active": data.get("last_contribution"),
|
||||
"claims_merged": data.get("claims_merged", 0) or 0,
|
||||
"principal": data.get("principal"),
|
||||
"role_breakdown": role_breakdown,
|
||||
"role_percentages": role_pct,
|
||||
"domain_breakdown": domain_breakdown,
|
||||
"review_stats": review_stats,
|
||||
"contribution_timeline": timeline,
|
||||
"active_domains": list(domain_breakdown.keys()),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _git_domain_breakdown(handle):
|
||||
"""For git-only contributors, count claims by domain from file paths."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--all", "--name-only", "--format=COMMIT|%an", "--diff-filter=A", "--", "domains/"],
|
||||
capture_output=True, text=True, cwd=CODEX_PATH, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
|
||||
domains = {}
|
||||
current_match = False
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("COMMIT|"):
|
||||
author = line.split("|", 1)[1]
|
||||
current_match = handle.lower() in author.lower()
|
||||
elif current_match and line.startswith("domains/"):
|
||||
parts = line.split("/")
|
||||
if len(parts) >= 2:
|
||||
domain = parts[1]
|
||||
domains[domain] = domains.get(domain, 0) + 1
|
||||
|
||||
return domains
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def handle_contributor_profile(request):
|
||||
from aiohttp import web
|
||||
handle = request.match_info["handle"]
|
||||
profile = get_contributor_profile(handle)
|
||||
if profile is None:
|
||||
return web.json_response({"error": f"Contributor '{handle}' not found"}, status=404)
|
||||
return web.json_response(profile)
|
||||
|
||||
|
||||
async def handle_contributors_list(request):
|
||||
from aiohttp import web
|
||||
conn = _get_conn()
|
||||
try:
|
||||
min_claims = int(request.query.get("min_claims", "1"))
|
||||
rows = conn.execute("""
|
||||
SELECT handle, display_name, first_contribution, last_contribution,
|
||||
sourcer_count, extractor_count, challenger_count, synthesizer_count,
|
||||
reviewer_count, claims_merged, principal
|
||||
FROM contributors
|
||||
WHERE claims_merged >= ?
|
||||
ORDER BY claims_merged DESC
|
||||
""", (min_claims,)).fetchall()
|
||||
|
||||
contributors = []
|
||||
for r in rows:
|
||||
data = dict(r)
|
||||
if data["handle"].lower() in SYSTEM_ACCOUNTS:
|
||||
continue
|
||||
ci = _compute_ci(data)
|
||||
action_ci = _get_action_ci(data["handle"], conn)
|
||||
action_total = action_ci["total"] if action_ci else 0.0
|
||||
contributors.append({
|
||||
"handle": data["handle"],
|
||||
"display_name": data["display_name"],
|
||||
"ci_score": ci,
|
||||
"action_ci": action_total,
|
||||
"primary_ci": action_total if action_total > 0 else ci,
|
||||
"claims_merged": data["claims_merged"],
|
||||
"first_contribution": data["first_contribution"],
|
||||
"last_contribution": data["last_contribution"],
|
||||
"principal": data["principal"],
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
"contributors": contributors,
|
||||
"total": len(contributors),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_contributor_routes(app):
|
||||
app.router.add_get("/api/contributors/list", handle_contributors_list)
|
||||
app.router.add_get("/api/contributors/{handle}", handle_contributor_profile)
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
"""Daily digest: aggregates 24h activity for Telegram bot consumption.
|
||||
|
||||
Data sources:
|
||||
- pipeline.db: merged PRs, audit events, contributor activity
|
||||
- Forgejo API: PR descriptions for claim summaries
|
||||
- claim-index: total claims, domain breakdown
|
||||
- review queue: pending approval counts
|
||||
|
||||
Endpoint: GET /api/daily-digest?hours=24
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger("argus.daily_digest")
|
||||
|
||||
FORGEJO_BASE = "https://git.livingip.xyz/api/v1"
|
||||
REPO = "teleo/teleo-codex"
|
||||
CLAIM_INDEX_URL = "http://localhost:8080/claim-index"
|
||||
|
||||
|
||||
async def fetch_daily_digest(
|
||||
db_path: str,
|
||||
forgejo_token: str | None = None,
|
||||
hours: int = 24,
|
||||
timeout_s: int = 15,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the daily digest payload.
|
||||
|
||||
Returns structured data for Epimetheus's Telegram bot to format and send.
|
||||
"""
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
|
||||
# Parallel: DB queries + HTTP fetches
|
||||
db_data = _query_db(db_path, cutoff, hours)
|
||||
|
||||
headers = {"Accept": "application/json"}
|
||||
if forgejo_token:
|
||||
headers["Authorization"] = f"token {forgejo_token}"
|
||||
|
||||
connector = aiohttp.TCPConnector(ssl=False)
|
||||
async with aiohttp.ClientSession(headers=headers, connector=connector) as session:
|
||||
# Fetch claim-index, merged PR details from Forgejo, and open PR count in parallel
|
||||
merged_numbers = [pr["number"] for pr in db_data["merged_prs"]]
|
||||
|
||||
tasks = [
|
||||
_fetch_claim_index(session, timeout_s),
|
||||
_fetch_merged_pr_details(session, merged_numbers, timeout_s),
|
||||
_fetch_open_pr_count(session, timeout_s),
|
||||
]
|
||||
claim_index, pr_details, open_pr_count = await asyncio.gather(*tasks)
|
||||
|
||||
# Enrich merged PRs with Forgejo descriptions
|
||||
merged_claims = _build_merged_claims(db_data["merged_prs"], pr_details)
|
||||
|
||||
return {
|
||||
"period_hours": hours,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"claims_merged": merged_claims,
|
||||
"pipeline_stats": {
|
||||
"prs_merged": db_data["prs_merged"],
|
||||
"prs_opened": db_data["prs_opened"],
|
||||
"prs_rejected": db_data["prs_rejected"],
|
||||
"approval_rate": db_data["approval_rate"],
|
||||
"top_rejection_reasons": db_data["top_rejection_reasons"],
|
||||
},
|
||||
"agent_activity": db_data["agent_activity"],
|
||||
"pending_review": {
|
||||
"open_prs": open_pr_count,
|
||||
},
|
||||
"knowledge_base": {
|
||||
"total_claims": claim_index.get("total_claims", 0),
|
||||
"domains": claim_index.get("domains", {}),
|
||||
"orphan_ratio": claim_index.get("orphan_ratio", 0),
|
||||
"cross_domain_links": claim_index.get("cross_domain_links", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _query_db(db_path: str, cutoff: str, hours: int) -> dict[str, Any]:
|
||||
"""Run all DB queries synchronously (SQLite is fast enough for digest)."""
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
# Merged PRs in period
|
||||
merged_prs = conn.execute(
|
||||
"""SELECT number, branch, domain, agent, commit_type, merged_at, cost_usd
|
||||
FROM prs WHERE status = 'merged' AND merged_at >= ?
|
||||
ORDER BY merged_at DESC""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
prs_merged = len(merged_prs)
|
||||
|
||||
# PRs opened in period
|
||||
prs_opened = conn.execute(
|
||||
"SELECT COUNT(*) FROM prs WHERE created_at >= ?", (cutoff,)
|
||||
).fetchone()[0]
|
||||
|
||||
# Rejected PRs in period (closed/zombie with rejection events)
|
||||
prs_rejected = conn.execute(
|
||||
"""SELECT COUNT(DISTINCT json_extract(detail, '$.pr'))
|
||||
FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
AND event IN ('domain_rejected', 'tier05_rejected')
|
||||
AND timestamp >= ?""",
|
||||
(cutoff,),
|
||||
).fetchone()[0]
|
||||
|
||||
# Approval rate
|
||||
total_evaluated = prs_merged + prs_rejected
|
||||
approval_rate = round(prs_merged / total_evaluated * 100, 1) if total_evaluated > 0 else 0.0
|
||||
|
||||
# Top rejection reasons
|
||||
rejection_rows = conn.execute(
|
||||
"""SELECT json_extract(detail, '$.issues') as issues
|
||||
FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
AND event IN ('domain_rejected', 'tier05_rejected')
|
||||
AND timestamp >= ?
|
||||
AND json_valid(detail)""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
reason_counts: dict[str, int] = {}
|
||||
import json
|
||||
for row in rejection_rows:
|
||||
if row["issues"]:
|
||||
try:
|
||||
issues = json.loads(row["issues"])
|
||||
if isinstance(issues, list):
|
||||
for issue in issues:
|
||||
reason_counts[issue] = reason_counts.get(issue, 0) + 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
top_rejection_reasons = sorted(reason_counts.items(), key=lambda x: -x[1])[:5]
|
||||
top_rejection_reasons = [{"reason": r, "count": c} for r, c in top_rejection_reasons]
|
||||
|
||||
# Agent activity — who contributed what
|
||||
agent_rows = conn.execute(
|
||||
"""SELECT agent,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'merged' THEN 1 ELSE 0 END) as merged,
|
||||
SUM(CASE WHEN commit_type = 'extract' OR commit_type = 'research' THEN 1 ELSE 0 END) as extractions,
|
||||
SUM(CASE WHEN commit_type = 'challenge' THEN 1 ELSE 0 END) as challenges,
|
||||
SUM(CASE WHEN commit_type = 'enrich' OR commit_type = 'reweave' THEN 1 ELSE 0 END) as enrichments,
|
||||
SUM(CASE WHEN commit_type = 'synthesize' THEN 1 ELSE 0 END) as syntheses
|
||||
FROM prs
|
||||
WHERE created_at >= ? AND agent IS NOT NULL AND agent != ''
|
||||
GROUP BY agent
|
||||
ORDER BY merged DESC""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
agent_activity = [
|
||||
{
|
||||
"agent": row["agent"],
|
||||
"prs_total": row["total"],
|
||||
"prs_merged": row["merged"],
|
||||
"extractions": row["extractions"],
|
||||
"challenges": row["challenges"],
|
||||
"enrichments": row["enrichments"],
|
||||
"syntheses": row["syntheses"],
|
||||
}
|
||||
for row in agent_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"merged_prs": [dict(pr) for pr in merged_prs],
|
||||
"prs_merged": prs_merged,
|
||||
"prs_opened": prs_opened,
|
||||
"prs_rejected": prs_rejected,
|
||||
"approval_rate": approval_rate,
|
||||
"top_rejection_reasons": top_rejection_reasons,
|
||||
"agent_activity": agent_activity,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _fetch_claim_index(session: aiohttp.ClientSession, timeout_s: int) -> dict:
|
||||
"""Fetch claim-index summary stats."""
|
||||
try:
|
||||
async with session.get(
|
||||
CLAIM_INDEX_URL,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout_s),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return {
|
||||
"total_claims": data.get("total_claims", 0),
|
||||
"domains": data.get("domains", {}),
|
||||
"orphan_ratio": data.get("orphan_ratio", 0),
|
||||
"cross_domain_links": data.get("cross_domain_links", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch claim-index: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
async def _fetch_merged_pr_details(
|
||||
session: aiohttp.ClientSession,
|
||||
pr_numbers: list[int],
|
||||
timeout_s: int,
|
||||
) -> dict[int, dict]:
|
||||
"""Fetch PR details from Forgejo for merged PRs (parallel)."""
|
||||
if not pr_numbers:
|
||||
return {}
|
||||
|
||||
async def _fetch_one(n: int) -> tuple[int, dict]:
|
||||
url = f"{FORGEJO_BASE}/repos/{REPO}/pulls/{n}"
|
||||
try:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout_s)) as resp:
|
||||
if resp.status == 200:
|
||||
return n, await resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch PR #%d: %s", n, e)
|
||||
return n, {}
|
||||
|
||||
results = await asyncio.gather(*[_fetch_one(n) for n in pr_numbers])
|
||||
return {n: data for n, data in results}
|
||||
|
||||
|
||||
async def _fetch_open_pr_count(session: aiohttp.ClientSession, timeout_s: int) -> int:
|
||||
"""Get count of open PRs from Forgejo."""
|
||||
url = f"{FORGEJO_BASE}/repos/{REPO}/pulls?state=open&limit=1"
|
||||
try:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout_s)) as resp:
|
||||
if resp.status == 200:
|
||||
# Forgejo returns X-Total-Count header
|
||||
total = resp.headers.get("X-Total-Count")
|
||||
if total is not None:
|
||||
return int(total)
|
||||
# Fallback: fetch all and count
|
||||
data = await resp.json()
|
||||
return len(data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch open PR count: %s", e)
|
||||
return 0
|
||||
|
||||
|
||||
def _build_merged_claims(
|
||||
merged_prs: list[dict],
|
||||
pr_details: dict[int, dict],
|
||||
) -> list[dict]:
|
||||
"""Build claim summaries from merged PRs + Forgejo PR bodies."""
|
||||
claims = []
|
||||
for pr in merged_prs:
|
||||
number = pr["number"]
|
||||
detail = pr_details.get(number, {})
|
||||
|
||||
# Extract summary from PR body (first paragraph or first 200 chars)
|
||||
body = detail.get("body", "") or ""
|
||||
summary = _extract_summary(body)
|
||||
|
||||
claims.append({
|
||||
"pr_number": number,
|
||||
"title": detail.get("title", pr.get("branch", f"PR #{number}")),
|
||||
"agent": pr.get("agent", "unknown"),
|
||||
"domain": pr.get("domain", "unknown"),
|
||||
"commit_type": pr.get("commit_type", "knowledge"),
|
||||
"summary": summary,
|
||||
"merged_at": pr.get("merged_at", ""),
|
||||
"cost_usd": pr.get("cost_usd", 0.0),
|
||||
"url": detail.get("html_url", ""),
|
||||
})
|
||||
|
||||
return claims
|
||||
|
||||
|
||||
def _extract_summary(body: str) -> str:
|
||||
"""Extract a 1-2 sentence summary from PR body markdown.
|
||||
|
||||
Looks for a Summary section first, then falls back to first non-header paragraph.
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
lines = body.strip().split("\n")
|
||||
|
||||
# Look for ## Summary section
|
||||
in_summary = False
|
||||
summary_lines = []
|
||||
for line in lines:
|
||||
if line.strip().lower().startswith("## summary"):
|
||||
in_summary = True
|
||||
continue
|
||||
if in_summary:
|
||||
if line.startswith("##"):
|
||||
break
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("- ["): # skip checklists
|
||||
summary_lines.append(stripped)
|
||||
if len(summary_lines) >= 3:
|
||||
break
|
||||
|
||||
if summary_lines:
|
||||
return " ".join(summary_lines)[:300]
|
||||
|
||||
# Fallback: first non-header, non-empty paragraph
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#") and not stripped.startswith("- ["):
|
||||
return stripped[:300]
|
||||
|
||||
return ""
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"""Route handlers for /api/daily-digest endpoint.
|
||||
|
||||
Import into app.py and register routes in create_app().
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from daily_digest import fetch_daily_digest
|
||||
|
||||
logger = logging.getLogger("argus.daily_digest")
|
||||
|
||||
|
||||
async def handle_daily_digest(request):
|
||||
"""GET /api/daily-digest — structured data for Telegram daily digest.
|
||||
|
||||
Query params:
|
||||
hours: lookback period in hours (default: 24, max: 168)
|
||||
|
||||
Returns JSON with:
|
||||
claims_merged: merged claims with summaries
|
||||
pipeline_stats: PRs merged/opened/rejected, approval rate, rejection reasons
|
||||
agent_activity: per-agent contribution breakdown
|
||||
pending_review: open PR count
|
||||
knowledge_base: total claims, domain breakdown, orphan ratio
|
||||
"""
|
||||
# Validate hours param
|
||||
try:
|
||||
hours = int(request.query.get("hours", 24))
|
||||
hours = max(1, min(hours, 168)) # clamp to 1h-7d
|
||||
except (ValueError, TypeError):
|
||||
hours = 24
|
||||
|
||||
db_path = request.app.get("_db_path")
|
||||
if not db_path:
|
||||
return web.json_response({"error": "database not configured"}, status=500)
|
||||
|
||||
token = request.app.get("_forgejo_token")
|
||||
|
||||
try:
|
||||
digest = await fetch_daily_digest(
|
||||
db_path=db_path,
|
||||
forgejo_token=token,
|
||||
hours=hours,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Daily digest fetch failed: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
return web.json_response(digest)
|
||||
|
||||
|
||||
def register_daily_digest_routes(app, db_path: str, forgejo_token: str | None = None):
|
||||
"""Register daily digest routes on the app.
|
||||
|
||||
db_path: path to pipeline.db
|
||||
forgejo_token: optional Forgejo API token
|
||||
"""
|
||||
app["_db_path"] = db_path
|
||||
if forgejo_token:
|
||||
app["_forgejo_token"] = forgejo_token
|
||||
app.router.add_get("/api/daily-digest", handle_daily_digest)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,348 +0,0 @@
|
|||
"""Page 3: Agent Performance — "Who's contributing what?"
|
||||
|
||||
Slim version v2 per Cory feedback (2026-04-03):
|
||||
- Hero: total merged, rejection rate, claims/week — 3 numbers
|
||||
- Table: agent, merged, rejection rate, last active, inbox depth — 5 columns
|
||||
- One chart: weekly contributions by agent (stacked bar)
|
||||
- No CI scores, no yield (redundant with rejection rate), no top issue (too granular)
|
||||
|
||||
Fetches /api/agents-dashboard + /api/agent-state, merges client-side.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shared_ui import render_page
|
||||
|
||||
|
||||
def render_agents_page(contributors_principal: list, contributors_agent: list, now: datetime) -> str:
|
||||
"""Render the slim Agent Performance page."""
|
||||
|
||||
body = """
|
||||
<!-- Hero Metrics (filled by JS) -->
|
||||
<div class="grid" id="hero-metrics">
|
||||
<div class="card" style="text-align:center;color:#8b949e">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-Agent Table -->
|
||||
<div class="section">
|
||||
<div class="section-title">Agent Breakdown (30d)</div>
|
||||
<div class="card">
|
||||
<table id="agent-table">
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th style="text-align:right">Merged</th>
|
||||
<th style="text-align:right">Rejection Rate</th>
|
||||
<th style="text-align:right">Last Active</th>
|
||||
<th style="text-align:right">Inbox</th>
|
||||
</tr>
|
||||
<tr><td colspan="5" style="color:#8b949e;text-align:center">Loading...</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Contributions Chart -->
|
||||
<div class="section">
|
||||
<div class="chart-container" style="max-width:100%">
|
||||
<h2>Claims Merged per Week by Agent</h2>
|
||||
<canvas id="trendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Scorecard (from review_records) -->
|
||||
<div class="section">
|
||||
<div class="section-title">Agent Scorecard (Structured Reviews)</div>
|
||||
<div class="card">
|
||||
<table id="scorecard-table">
|
||||
<tr><td colspan="7" style="color:#8b949e;text-align:center">Loading...</td></tr>
|
||||
</table>
|
||||
<div id="scorecard-rejections" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Session Digests -->
|
||||
<div class="section">
|
||||
<div class="section-title">Latest Session Digests</div>
|
||||
<div id="digest-container">
|
||||
<div class="card" style="text-align:center;color:#8b949e">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
scripts = """<script>
|
||||
Promise.all([
|
||||
fetch('/api/agents-dashboard?days=30').then(r => r.json()),
|
||||
fetch('/api/agent-state').then(r => r.json()).catch(() => ({agents: {}}))
|
||||
])
|
||||
.then(([data, stateData]) => {
|
||||
const agents = data.agents || {};
|
||||
const agentState = stateData.agents || {};
|
||||
|
||||
// Sort by approved desc, filter to agents with evals
|
||||
const sorted = Object.entries(agents)
|
||||
.filter(([_, a]) => a.evaluated > 0)
|
||||
.sort((a, b) => (b[1].approved || 0) - (a[1].approved || 0));
|
||||
|
||||
// --- Hero metrics ---
|
||||
let totalMerged = 0, totalRejected = 0, totalEval = 0;
|
||||
const weekMerged = {};
|
||||
for (const [_, a] of sorted) {
|
||||
totalMerged += a.approved || 0;
|
||||
totalRejected += a.rejected || 0;
|
||||
totalEval += a.evaluated || 0;
|
||||
if (a.weekly_trend) {
|
||||
a.weekly_trend.forEach(w => {
|
||||
weekMerged[w.week] = (weekMerged[w.week] || 0) + (w.merged || 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const weeks = Object.keys(weekMerged).sort();
|
||||
const recentWeeks = weeks.slice(-4);
|
||||
const claimsPerWeek = recentWeeks.length > 0
|
||||
? Math.round(recentWeeks.reduce((s, w) => s + weekMerged[w], 0) / recentWeeks.length)
|
||||
: 0;
|
||||
const rejRate = totalEval > 0 ? ((totalRejected / totalEval) * 100).toFixed(1) : '0';
|
||||
|
||||
document.getElementById('hero-metrics').innerHTML =
|
||||
'<div class="card" style="text-align:center">' +
|
||||
'<div class="label">Claims Merged (30d)</div>' +
|
||||
'<div style="font-size:32px;font-weight:700;color:#3fb950">' + totalMerged + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="card" style="text-align:center">' +
|
||||
'<div class="label">Rejection Rate</div>' +
|
||||
'<div style="font-size:32px;font-weight:700;color:' + (parseFloat(rejRate) > 30 ? '#f85149' : '#e3b341') + '">' + rejRate + '%</div>' +
|
||||
'</div>' +
|
||||
'<div class="card" style="text-align:center">' +
|
||||
'<div class="label">Claims/Week (avg last 4w)</div>' +
|
||||
'<div style="font-size:32px;font-weight:700;color:#58a6ff">' + claimsPerWeek + '</div>' +
|
||||
'</div>';
|
||||
|
||||
// --- Per-agent table ---
|
||||
if (sorted.length === 0) {
|
||||
document.getElementById('agent-table').innerHTML =
|
||||
'<tr><th>Agent</th><th>Merged</th><th>Rejection Rate</th><th>Last Active</th><th>Inbox</th></tr>' +
|
||||
'<tr><td colspan="5" style="color:#8b949e;text-align:center">No evaluation data yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper: format relative time
|
||||
function timeAgo(isoStr) {
|
||||
if (!isoStr) return '<span style="color:#484f58">unknown</span>';
|
||||
const diff = (Date.now() - new Date(isoStr).getTime()) / 1000;
|
||||
if (diff < 3600) return Math.round(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.round(diff / 3600) + 'h ago';
|
||||
return Math.round(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
let tableHtml = '<tr><th>Agent</th><th style="text-align:right">Merged</th>' +
|
||||
'<th style="text-align:right">Rejection Rate</th>' +
|
||||
'<th style="text-align:right">Last Active</th>' +
|
||||
'<th style="text-align:right">Inbox</th></tr>';
|
||||
|
||||
for (const [name, a] of sorted) {
|
||||
const color = agentColor(name);
|
||||
const rr = a.evaluated > 0 ? ((a.rejected / a.evaluated) * 100).toFixed(1) + '%' : '-';
|
||||
const rrColor = a.rejection_rate > 0.3 ? '#f85149' : a.rejection_rate > 0.15 ? '#e3b341' : '#3fb950';
|
||||
|
||||
// Agent state lookup (case-insensitive match)
|
||||
const stateKey = Object.keys(agentState).find(k => k.toLowerCase() === name.toLowerCase()) || '';
|
||||
const state = agentState[stateKey] || {};
|
||||
const lastActive = timeAgo(state.last_active);
|
||||
const inboxDepth = state.inbox_depth != null ? state.inbox_depth : '-';
|
||||
const inboxColor = inboxDepth > 10 ? '#f85149' : inboxDepth > 5 ? '#d29922' : inboxDepth > 0 ? '#58a6ff' : '#3fb950';
|
||||
|
||||
tableHtml += '<tr>' +
|
||||
'<td><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + color + ';margin-right:6px"></span>' + esc(name) + '</td>' +
|
||||
'<td style="text-align:right;font-weight:600;color:#3fb950">' + (a.approved || 0) + '</td>' +
|
||||
'<td style="text-align:right;color:' + rrColor + '">' + rr + '</td>' +
|
||||
'<td style="text-align:right">' + lastActive + '</td>' +
|
||||
'<td style="text-align:right;color:' + inboxColor + '">' + inboxDepth + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
document.getElementById('agent-table').innerHTML = tableHtml;
|
||||
|
||||
// --- Weekly trend chart ---
|
||||
const allWeeks = new Set();
|
||||
const agentNames = [];
|
||||
for (const [name, a] of sorted) {
|
||||
if (a.weekly_trend && a.weekly_trend.length > 0) {
|
||||
agentNames.push(name);
|
||||
a.weekly_trend.forEach(w => allWeeks.add(w.week));
|
||||
}
|
||||
}
|
||||
const sortedWeeks = [...allWeeks].sort();
|
||||
|
||||
if (sortedWeeks.length > 0 && agentNames.length > 0) {
|
||||
const trendMap = {};
|
||||
for (const [name, a] of sorted) {
|
||||
if (a.weekly_trend) {
|
||||
trendMap[name] = {};
|
||||
a.weekly_trend.forEach(w => { trendMap[name][w.week] = w.merged; });
|
||||
}
|
||||
}
|
||||
|
||||
new Chart(document.getElementById('trendChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: sortedWeeks,
|
||||
datasets: agentNames.map(name => ({
|
||||
label: name,
|
||||
data: sortedWeeks.map(w => (trendMap[name] || {})[w] || 0),
|
||||
backgroundColor: agentColor(name),
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false } },
|
||||
y: { stacked: true, title: { display: true, text: 'Claims Merged' }, min: 0 },
|
||||
},
|
||||
plugins: { legend: { labels: { boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
document.getElementById('hero-metrics').innerHTML =
|
||||
'<div class="card" style="grid-column:1/-1;text-align:center;color:#f85149">Failed to load: ' + err.message + '</div>';
|
||||
});
|
||||
|
||||
// --- Agent Scorecard ---
|
||||
fetch('/api/agent-scorecard')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const cards = data.scorecards || [];
|
||||
if (cards.length === 0 || cards.every(c => c.total_reviews === 0)) {
|
||||
document.getElementById('scorecard-table').innerHTML =
|
||||
'<tr><td colspan="7" style="color:#8b949e;text-align:center">No structured review data yet (review_records table is empty)</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<tr><th>Agent</th><th style="text-align:right">PRs</th><th style="text-align:right">Reviews</th>' +
|
||||
'<th style="text-align:right">Approved</th><th style="text-align:right">w/ Changes</th>' +
|
||||
'<th style="text-align:right">Rejected</th><th style="text-align:right">Approval Rate</th></tr>';
|
||||
|
||||
const allReasons = {};
|
||||
for (const c of cards) {
|
||||
const arColor = c.approval_rate >= 80 ? '#3fb950' : c.approval_rate >= 60 ? '#d29922' : '#f85149';
|
||||
html += '<tr>' +
|
||||
'<td><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + agentColor(c.agent) + ';margin-right:6px"></span>' + esc(c.agent) + '</td>' +
|
||||
'<td style="text-align:right">' + c.total_prs + '</td>' +
|
||||
'<td style="text-align:right">' + c.total_reviews + '</td>' +
|
||||
'<td style="text-align:right;color:#3fb950">' + c.approved + '</td>' +
|
||||
'<td style="text-align:right;color:#d29922">' + c.approved_with_changes + '</td>' +
|
||||
'<td style="text-align:right;color:#f85149">' + c.rejected + '</td>' +
|
||||
'<td style="text-align:right;font-weight:600;color:' + arColor + '">' + c.approval_rate.toFixed(1) + '%</td>' +
|
||||
'</tr>';
|
||||
if (c.rejection_reasons) {
|
||||
for (const [reason, cnt] of Object.entries(c.rejection_reasons)) {
|
||||
allReasons[reason] = (allReasons[reason] || 0) + cnt;
|
||||
}
|
||||
}
|
||||
}
|
||||
document.getElementById('scorecard-table').innerHTML = html;
|
||||
|
||||
// Top rejection reasons across all agents
|
||||
const sortedReasons = Object.entries(allReasons).sort((a, b) => b[1] - a[1]);
|
||||
if (sortedReasons.length > 0) {
|
||||
let rHtml = '<div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:6px;text-transform:uppercase">Top Rejection Reasons</div>';
|
||||
rHtml += sortedReasons.map(([reason, cnt]) =>
|
||||
'<span style="display:inline-block;margin:2px 4px;padding:3px 10px;background:#f8514922;border:1px solid #f8514944;border-radius:12px;font-size:12px;color:#f85149">' +
|
||||
esc(reason) + ' <strong>' + cnt + '</strong></span>'
|
||||
).join('');
|
||||
rHtml += '<div style="margin-top:8px;font-size:11px;color:#484f58">Target: 80% approval rate. Too high = too conservative, too low = wasting pipeline compute.</div>';
|
||||
document.getElementById('scorecard-rejections').innerHTML = rHtml;
|
||||
}
|
||||
}).catch(() => {
|
||||
document.getElementById('scorecard-table').innerHTML =
|
||||
'<tr><td colspan="7" style="color:#8b949e;text-align:center">Failed to load scorecard</td></tr>';
|
||||
});
|
||||
|
||||
// --- Latest Session Digests ---
|
||||
fetch('/api/session-digest?latest=true')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const digests = data.digests || [];
|
||||
if (digests.length === 0) {
|
||||
document.getElementById('digest-container').innerHTML =
|
||||
'<div class="card" style="text-align:center;color:#8b949e">No session digests yet. Data starts flowing when agents complete research sessions.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(320px, 1fr))">';
|
||||
for (const d of digests) {
|
||||
const color = agentColor(d.agent);
|
||||
const dateStr = d.date || d.timestamp || '';
|
||||
|
||||
html += '<div class="card" style="border-left:3px solid ' + color + '">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
|
||||
'<strong style="color:' + color + '">' + esc(d.agent || 'unknown') + '</strong>' +
|
||||
'<span style="font-size:11px;color:#484f58">' + esc(dateStr) + '</span>' +
|
||||
'</div>';
|
||||
|
||||
if (d.research_question) {
|
||||
html += '<div style="font-size:13px;font-style:italic;color:#c9d1d9;margin-bottom:8px">' + esc(d.research_question) + '</div>';
|
||||
}
|
||||
|
||||
if (d.key_findings && d.key_findings.length > 0) {
|
||||
html += '<div style="font-size:11px;color:#8b949e;text-transform:uppercase;margin-bottom:4px">Key Findings</div><ul style="margin:0 0 8px 16px;font-size:12px">';
|
||||
for (const f of d.key_findings) html += '<li>' + esc(f) + '</li>';
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
if (d.surprises && d.surprises.length > 0) {
|
||||
html += '<div style="font-size:11px;color:#8b949e;text-transform:uppercase;margin-bottom:4px">Surprises</div><ul style="margin:0 0 8px 16px;font-size:12px">';
|
||||
for (const s of d.surprises) html += '<li>' + esc(s) + '</li>';
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
if (d.confidence_shifts && d.confidence_shifts.length > 0) {
|
||||
html += '<div style="font-size:11px;color:#8b949e;text-transform:uppercase;margin-bottom:4px">Confidence Shifts</div>';
|
||||
for (const cs of d.confidence_shifts) {
|
||||
const arrow = cs.direction === 'up' ? '▲' : cs.direction === 'down' ? '▼' : '▶';
|
||||
const arrowColor = cs.direction === 'up' ? '#3fb950' : cs.direction === 'down' ? '#f85149' : '#d29922';
|
||||
html += '<div style="font-size:12px;margin-left:16px"><span style="color:' + arrowColor + '">' + arrow + '</span> ' + esc(cs.claim || cs.topic || '') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Expandable details
|
||||
const detailId = 'digest-detail-' + Math.random().toString(36).substr(2, 6);
|
||||
const hasDetails = (d.sources_archived && d.sources_archived.length > 0) ||
|
||||
(d.prs_submitted && d.prs_submitted.length > 0) ||
|
||||
(d.follow_ups && d.follow_ups.length > 0);
|
||||
if (hasDetails) {
|
||||
html += '<a style="color:#58a6ff;cursor:pointer;font-size:11px;display:block;margin-top:6px" ' +
|
||||
'onclick="var e=document.getElementById(\\x27' + detailId + '\\x27);e.style.display=e.style.display===\\x27none\\x27?\\x27block\\x27:\\x27none\\x27">Details</a>';
|
||||
html += '<div id="' + detailId + '" style="display:none;margin-top:6px;font-size:12px">';
|
||||
if (d.sources_archived && d.sources_archived.length > 0) {
|
||||
html += '<div style="color:#8b949e;font-size:11px">Sources: ' + d.sources_archived.length + '</div>';
|
||||
}
|
||||
if (d.prs_submitted && d.prs_submitted.length > 0) {
|
||||
html += '<div style="color:#8b949e;font-size:11px">PRs: ' + d.prs_submitted.map(p => '#' + p).join(', ') + '</div>';
|
||||
}
|
||||
if (d.follow_ups && d.follow_ups.length > 0) {
|
||||
html += '<div style="color:#8b949e;font-size:11px;margin-top:4px">Follow-ups:</div><ul style="margin:2px 0 0 16px">';
|
||||
for (const fu of d.follow_ups) html += '<li>' + esc(fu) + '</li>';
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
document.getElementById('digest-container').innerHTML = html;
|
||||
}).catch(() => {
|
||||
document.getElementById('digest-container').innerHTML =
|
||||
'<div class="card" style="text-align:center;color:#8b949e">Failed to load session digests</div>';
|
||||
});
|
||||
</script>"""
|
||||
|
||||
return render_page(
|
||||
title="Agent Performance",
|
||||
subtitle="Who's contributing what?",
|
||||
active_path="/agents",
|
||||
body_html=body,
|
||||
scripts=scripts,
|
||||
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
"""Page 4: Epistemic Integrity — "Can we trust what we know?"
|
||||
|
||||
Live sections:
|
||||
- Confidence calibration (from claim-index via vital signs)
|
||||
- Cascade coverage (from audit_log stage='cascade')
|
||||
- Review quality (from review_records table)
|
||||
|
||||
Placeholder sections:
|
||||
- Multi-model agreement (needs model_evals table)
|
||||
- Belief staleness (needs cascade tracking to give it meaning)
|
||||
- Divergence tracking (needs divergence events)
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from shared_ui import render_page
|
||||
|
||||
|
||||
def render_epistemic_page(vital_signs: dict, now: datetime) -> str:
|
||||
"""Render the Epistemic Integrity page."""
|
||||
|
||||
vs_conf = vital_signs.get("confidence_distribution", {})
|
||||
total_claims = sum(vs_conf.values()) if vs_conf else 0
|
||||
|
||||
# Confidence calibration table
|
||||
conf_rows = ""
|
||||
for level in ["proven", "likely", "experimental", "speculative"]:
|
||||
count = vs_conf.get(level, 0)
|
||||
pct = round(count / total_claims * 100, 1) if total_claims else 0
|
||||
conf_rows += f'<tr><td>{level}</td><td>{count}</td><td>{pct}%</td></tr>'
|
||||
|
||||
body = f"""
|
||||
<!-- Confidence Calibration (LIVE) -->
|
||||
<div class="section">
|
||||
<div class="section-title">Confidence Calibration</div>
|
||||
<div class="row">
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Level</th><th>Claims</th><th>Share</th></tr>
|
||||
{conf_rows}
|
||||
</table>
|
||||
<div style="margin-top:12px;font-size:12px;color:#8b949e">
|
||||
Total claims: {total_claims}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h2>Confidence Distribution</h2>
|
||||
<canvas id="confPieChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cascade Coverage (LIVE — from audit_log) -->
|
||||
<div class="section">
|
||||
<div class="section-title">Cascade Coverage</div>
|
||||
<div id="cascade-container">
|
||||
<div class="card" style="text-align:center;color:#8b949e">Loading cascade data...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Quality (LIVE — from review_records table) -->
|
||||
<div class="section">
|
||||
<div class="section-title">Review Quality</div>
|
||||
<div id="review-container">
|
||||
<div class="card" style="text-align:center;color:#8b949e">Loading review data...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Model Agreement — Placeholder -->
|
||||
<div class="section">
|
||||
<div class="section-title">Multi-Model Agreement</div>
|
||||
<div class="card" style="text-align:center;padding:40px">
|
||||
<div style="font-size:40px;margin-bottom:12px;opacity:0.3">⚙</div>
|
||||
<div style="color:#8b949e">
|
||||
Multi-model agreement rate requires the <code>model_evals</code> table.<br>
|
||||
<span style="font-size:12px">Blocked on: model_evals table creation (Ship Phase 3)</span>
|
||||
</div>
|
||||
<div style="margin-top:16px;font-size:12px;color:#8b949e">
|
||||
Current eval models: Haiku (triage), GPT-4o (domain), Sonnet/Opus (Leo).<br>
|
||||
Agreement tracking needs per-model verdicts stored separately.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Belief Staleness — Placeholder -->
|
||||
<div class="section">
|
||||
<div class="section-title">Belief Staleness</div>
|
||||
<div class="card" style="text-align:center;padding:40px">
|
||||
<div style="font-size:40px;margin-bottom:12px;opacity:0.3">⏲</div>
|
||||
<div style="color:#8b949e">
|
||||
Belief staleness scan will compare belief file <code>depends_on</code> frontmatter<br>
|
||||
against claim <code>merged_at</code> timestamps.<br>
|
||||
<span style="font-size:12px">Ready to implement once cascade tracking accumulates data</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
scripts = f"""<script>
|
||||
// Confidence pie chart
|
||||
const confData = {json.dumps(vs_conf)};
|
||||
const confLabels = Object.keys(confData);
|
||||
const confValues = Object.values(confData);
|
||||
if (confLabels.length > 0) {{
|
||||
const confColors = {{ 'proven': '#3fb950', 'likely': '#58a6ff', 'experimental': '#d29922', 'speculative': '#f85149', 'unknown': '#8b949e' }};
|
||||
new Chart(document.getElementById('confPieChart'), {{
|
||||
type: 'doughnut',
|
||||
data: {{
|
||||
labels: confLabels,
|
||||
datasets: [{{
|
||||
data: confValues,
|
||||
backgroundColor: confLabels.map(l => confColors[l] || '#8b949e'),
|
||||
borderColor: '#161b22',
|
||||
borderWidth: 2,
|
||||
}}],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
plugins: {{
|
||||
legend: {{ position: 'right', labels: {{ boxWidth: 12 }} }},
|
||||
}},
|
||||
}},
|
||||
}});
|
||||
}}
|
||||
|
||||
// --- Cascade Coverage (live) ---
|
||||
fetch('/api/cascade-coverage?days=30')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
const el = document.getElementById('cascade-container');
|
||||
if (data.total_triggered === 0) {{
|
||||
el.innerHTML = `
|
||||
<div class="card" style="text-align:center;padding:30px">
|
||||
<div style="font-size:14px;color:#d29922">No cascade events recorded yet</div>
|
||||
<div style="font-size:12px;color:#8b949e;margin-top:8px">
|
||||
Cascade instrumentation is deployed. Events will appear as new PRs flow through eval and trigger belief/position reviews.
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}}
|
||||
|
||||
const compRate = data.completion_rate != null ? (data.completion_rate * 100).toFixed(1) + '%' : '--';
|
||||
const compColor = data.completion_rate >= 0.7 ? '#3fb950' : data.completion_rate >= 0.4 ? '#d29922' : '#f85149';
|
||||
|
||||
let agentRows = '';
|
||||
for (const a of (data.by_agent || [])) {{
|
||||
agentRows += '<tr><td>' + esc(a.agent) + '</td><td>' + a.triggered + '</td><td>' + a.claims_affected + '</td></tr>';
|
||||
}}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Cascades Triggered</div><div class="hero-value">${{data.total_triggered}}</div></div>
|
||||
<div class="card"><div class="label">Cascades Reviewed</div><div class="hero-value">${{data.total_reviewed}}</div></div>
|
||||
<div class="card"><div class="label">Completion Rate</div><div class="hero-value" style="color:${{compColor}}">${{compRate}}</div></div>
|
||||
<div class="card"><div class="label">Merges w/ Cascade</div><div class="hero-value">${{data.merges_with_cascade}}</div></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:12px">
|
||||
<table>
|
||||
<tr><th>Agent</th><th>Cascades Triggered</th><th>Claims Affected</th></tr>
|
||||
${{agentRows || '<tr><td colspan="3" style="color:#8b949e">No per-agent data</td></tr>'}}
|
||||
</table>
|
||||
</div>`;
|
||||
}}).catch(() => {{
|
||||
document.getElementById('cascade-container').innerHTML =
|
||||
'<div class="card" style="color:#f85149">Failed to load cascade data</div>';
|
||||
}});
|
||||
|
||||
// --- Review Quality (live from review_records) ---
|
||||
fetch('/api/review-summary?days=30')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
const el = document.getElementById('review-container');
|
||||
if (!data.populated) {{
|
||||
el.innerHTML = `
|
||||
<div class="card" style="text-align:center;padding:30px">
|
||||
<div style="font-size:14px;color:#d29922">Review records table is empty</div>
|
||||
<div style="font-size:12px;color:#8b949e;margin-top:8px">
|
||||
review_records (migration v12) is deployed. Structured review data will populate as new PRs are evaluated.
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}}
|
||||
|
||||
const outcomes = data.outcomes || {{}};
|
||||
const approved = (outcomes['approved'] || 0) + (outcomes['approved-with-changes'] || 0);
|
||||
const rejected = outcomes['rejected'] || 0;
|
||||
const approvalRate = data.total > 0 ? ((approved / data.total) * 100).toFixed(1) : '--';
|
||||
const approvalColor = approved / data.total >= 0.7 ? '#3fb950' : approved / data.total >= 0.5 ? '#d29922' : '#f85149';
|
||||
|
||||
// Rejection reasons
|
||||
let reasonRows = '';
|
||||
for (const r of (data.rejection_reasons || [])) {{
|
||||
reasonRows += '<tr><td><code>' + esc(r.reason) + '</code></td><td>' + r.count + '</td></tr>';
|
||||
}}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Total Reviews</div><div class="hero-value">${{data.total}}</div></div>
|
||||
<div class="card"><div class="label">Approval Rate</div><div class="hero-value" style="color:${{approvalColor}}">${{approvalRate}}%</div></div>
|
||||
<div class="card"><div class="label">Approved w/ Changes</div><div class="hero-value" style="color:#d29922">${{outcomes['approved-with-changes'] || 0}}</div></div>
|
||||
<div class="card"><div class="label">Rejected</div><div class="hero-value" style="color:#f85149">${{rejected}}</div></div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<div class="card">
|
||||
<div style="font-weight:600;margin-bottom:8px">Rejection Reasons</div>
|
||||
<table>
|
||||
<tr><th>Reason</th><th>Count</th></tr>
|
||||
${{reasonRows || '<tr><td colspan="2" style="color:#8b949e">No rejections</td></tr>'}}
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}}).catch(() => {{
|
||||
document.getElementById('review-container').innerHTML =
|
||||
'<div class="card" style="color:#f85149">Failed to load review data</div>';
|
||||
}});
|
||||
</script>"""
|
||||
|
||||
return render_page(
|
||||
title="Epistemic Integrity",
|
||||
subtitle="Can we trust what we know?",
|
||||
active_path="/epistemic",
|
||||
body_html=body,
|
||||
scripts=scripts,
|
||||
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
"""Page 2: Knowledge Health — "What do we know and how good is it?"
|
||||
|
||||
Renders: claims by domain, Herfindahl index, evidence freshness,
|
||||
orphan ratio, link density, confidence distribution, extraction yield.
|
||||
|
||||
Data sources: /api/vital-signs, /api/herfindahl, /api/extraction-yield-by-domain,
|
||||
/api/domains, claim-index (cached).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from shared_ui import render_page
|
||||
|
||||
|
||||
def render_health_page(vital_signs: dict, domain_breakdown: dict, now: datetime) -> str:
|
||||
"""Render the Knowledge Health page."""
|
||||
|
||||
# --- Vital signs data ---
|
||||
vs_orphan = vital_signs.get("orphan_ratio", {})
|
||||
orphan_ratio_val = vs_orphan.get("ratio")
|
||||
orphan_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_orphan.get("status", ""), "")
|
||||
orphan_display = f"{orphan_ratio_val:.1%}" if orphan_ratio_val is not None else "—"
|
||||
|
||||
vs_linkage = vital_signs.get("linkage_density") or {}
|
||||
linkage_display = f'{vs_linkage.get("avg_outgoing_links", "—")}'
|
||||
cross_domain_ratio = vs_linkage.get("cross_domain_ratio")
|
||||
cross_domain_color = "green" if cross_domain_ratio and cross_domain_ratio >= 0.15 else (
|
||||
"yellow" if cross_domain_ratio and cross_domain_ratio >= 0.05 else "red"
|
||||
) if cross_domain_ratio is not None else ""
|
||||
|
||||
vs_fresh = vital_signs.get("evidence_freshness") or {}
|
||||
fresh_display = f'{vs_fresh.get("median_age_days", "—")}' if vs_fresh.get("median_age_days") else "—"
|
||||
fresh_pct = vs_fresh.get("fresh_30d_pct", 0)
|
||||
|
||||
vs_conf = vital_signs.get("confidence_distribution", {})
|
||||
|
||||
# Domain activity
|
||||
stagnant = vital_signs.get("domain_activity", {}).get("stagnant", [])
|
||||
active_domains = vital_signs.get("domain_activity", {}).get("active", [])
|
||||
|
||||
claim_status = vital_signs.get("claim_index_status", "unavailable")
|
||||
|
||||
# Domain breakdown table
|
||||
domain_rows = ""
|
||||
for domain, stats in sorted(domain_breakdown.items(), key=lambda x: x[1].get("knowledge_prs", 0), reverse=True):
|
||||
if stats.get("knowledge_prs", 0) > 0:
|
||||
top_contribs = ", ".join(f'{c["handle"]} ({c["claims"]})' for c in stats.get("contributors", [])[:3])
|
||||
domain_rows += f"""<tr>
|
||||
<td style="color:#58a6ff">{domain}</td>
|
||||
<td>{stats["knowledge_prs"]}</td>
|
||||
<td>{stats["total_prs"]}</td>
|
||||
<td style="font-size:12px;color:#8b949e">{top_contribs}</td>
|
||||
</tr>"""
|
||||
|
||||
body = f"""
|
||||
<!-- Vital Signs Cards -->
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">Orphan Ratio</div>
|
||||
<div class="value {orphan_color}">{orphan_display}</div>
|
||||
<div class="detail">{vs_orphan.get("count", "?")} / {vs_orphan.get("total", "?")} claims · target <15%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Avg Links/Claim</div>
|
||||
<div class="value">{linkage_display}</div>
|
||||
<div class="detail">cross-domain: <span class="{cross_domain_color}">{f"{cross_domain_ratio:.1%}" if cross_domain_ratio is not None else "—"}</span> · target 15-30%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Evidence Freshness</div>
|
||||
<div class="value">{fresh_display}<span style="font-size:14px;color:#8b949e">d median</span></div>
|
||||
<div class="detail">{vs_fresh.get("fresh_30d_count", "?")} claims <30d old · {fresh_pct:.0f}% fresh</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Confidence Spread</div>
|
||||
<div class="value" style="font-size:16px">{" / ".join(f"{vs_conf.get(k, 0)}" for k in ["proven", "likely", "experimental", "speculative"])}</div>
|
||||
<div class="detail">proven / likely / experimental / speculative</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Claim Index</div>
|
||||
<div class="value {'green' if claim_status == 'live' else 'red'}">{claim_status}</div>
|
||||
<div class="detail">{vs_orphan.get("total", "?")} claims indexed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Herfindahl + Domain Yield (loaded via JS) -->
|
||||
<div class="row">
|
||||
<div class="section">
|
||||
<div class="section-title">Domain Concentration</div>
|
||||
<div id="herfindahl-container" class="card" style="text-align:center;padding:24px">
|
||||
<div class="label">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Extraction Yield by Domain</div>
|
||||
<div id="yield-domain-container" class="card">
|
||||
<div style="color:#8b949e;text-align:center;padding:16px">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row">
|
||||
<div class="chart-container">
|
||||
<h2>Claims by Domain</h2>
|
||||
<canvas id="domainChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h2>Confidence Distribution</h2>
|
||||
<canvas id="confidenceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Breakdown Table -->
|
||||
<div class="section">
|
||||
<div class="section-title">Contributions by Domain</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Domain</th><th>Knowledge PRs</th><th>Total PRs</th><th>Top Contributors</th></tr>
|
||||
{domain_rows if domain_rows else "<tr><td colspan='4' style='color:#8b949e'>No domain data</td></tr>"}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stagnation Alerts -->
|
||||
{"" if not stagnant else f'''
|
||||
<div class="section">
|
||||
<div class="section-title" style="color:#d29922">Stagnation Alerts</div>
|
||||
<div class="card">
|
||||
<p style="color:#d29922">Domains with no PR activity in 7 days: <strong>{", ".join(stagnant)}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
'''}
|
||||
"""
|
||||
|
||||
scripts = f"""<script>
|
||||
// --- Herfindahl index ---
|
||||
fetch('/api/herfindahl?days=30')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
const container = document.getElementById('herfindahl-container');
|
||||
const statusColor = data.status === 'diverse' ? 'green' : data.status === 'moderate' ? 'yellow' : 'red';
|
||||
let domainsHtml = data.domains.map(d =>
|
||||
'<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #21262d">' +
|
||||
'<span>' + esc(d.domain) + '</span>' +
|
||||
'<span style="color:#8b949e">' + d.count + ' (' + (d.share * 100).toFixed(1) + '%)</span></div>'
|
||||
).join('');
|
||||
container.innerHTML =
|
||||
'<div class="value ' + statusColor + '">' + data.hhi.toFixed(4) + '</div>' +
|
||||
'<div class="detail">' + data.status + ' · ' + data.total_merged + ' merged (30d)</div>' +
|
||||
'<div style="margin-top:12px;text-align:left">' + domainsHtml + '</div>';
|
||||
}}).catch(() => {{}});
|
||||
|
||||
// --- Extraction yield by domain ---
|
||||
fetch('/api/extraction-yield-by-domain?days=30')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
const container = document.getElementById('yield-domain-container');
|
||||
if (!data.domains || data.domains.length === 0) {{
|
||||
container.innerHTML = '<div style="color:#8b949e;text-align:center;padding:16px">No yield data</div>';
|
||||
return;
|
||||
}}
|
||||
let html = '<table><tr><th>Domain</th><th>PRs</th><th>Merged</th><th>Yield</th></tr>';
|
||||
data.domains.forEach(d => {{
|
||||
const yieldColor = d.yield >= 0.5 ? 'green' : d.yield >= 0.3 ? 'yellow' : 'red';
|
||||
html += '<tr><td>' + esc(d.domain) + '</td><td>' + d.total_prs + '</td>' +
|
||||
'<td>' + d.merged + '</td><td class="' + yieldColor + '">' + (d.yield * 100).toFixed(1) + '%</td></tr>';
|
||||
}});
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
}}).catch(() => {{}});
|
||||
|
||||
// --- Domain distribution chart ---
|
||||
const domainData = {json.dumps({d: s.get("knowledge_prs", 0) for d, s in domain_breakdown.items() if s.get("knowledge_prs", 0) > 0})};
|
||||
const domainLabels = Object.keys(domainData);
|
||||
const domainValues = Object.values(domainData);
|
||||
if (domainLabels.length > 0) {{
|
||||
const colors = ['#58a6ff', '#3fb950', '#d29922', '#f0883e', '#bc8cff', '#f85149', '#8b949e', '#ec4899'];
|
||||
new Chart(document.getElementById('domainChart'), {{
|
||||
type: 'doughnut',
|
||||
data: {{
|
||||
labels: domainLabels,
|
||||
datasets: [{{ data: domainValues, backgroundColor: domainLabels.map((_, i) => colors[i % colors.length]), borderColor: '#161b22', borderWidth: 2 }}],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
plugins: {{ legend: {{ position: 'right', labels: {{ boxWidth: 12, font: {{ size: 11 }} }} }} }},
|
||||
}},
|
||||
}});
|
||||
}}
|
||||
|
||||
// --- Confidence distribution chart ---
|
||||
const confData = {json.dumps(vs_conf)};
|
||||
const confLabels = Object.keys(confData);
|
||||
const confValues = Object.values(confData);
|
||||
if (confLabels.length > 0) {{
|
||||
const confColors = {{ 'proven': '#3fb950', 'likely': '#58a6ff', 'experimental': '#d29922', 'speculative': '#f85149', 'unknown': '#8b949e' }};
|
||||
new Chart(document.getElementById('confidenceChart'), {{
|
||||
type: 'bar',
|
||||
data: {{
|
||||
labels: confLabels,
|
||||
datasets: [{{ data: confValues, backgroundColor: confLabels.map(l => confColors[l] || '#8b949e') }}],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
plugins: {{ legend: {{ display: false }} }},
|
||||
scales: {{
|
||||
y: {{ title: {{ display: true, text: 'Claims' }}, min: 0 }},
|
||||
x: {{ grid: {{ display: false }} }},
|
||||
}},
|
||||
}},
|
||||
}});
|
||||
}}
|
||||
</script>"""
|
||||
|
||||
return render_page(
|
||||
title="Knowledge Health",
|
||||
subtitle="What do we know and how good is it?",
|
||||
active_path="/health",
|
||||
body_html=body,
|
||||
scripts=scripts,
|
||||
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
"""Page 1: Pipeline Operations — "Is the machine running?"
|
||||
|
||||
Renders: queue depth, throughput, error rate, stage flow, breakers,
|
||||
funnel, rejection reasons, fix cycle, time-series charts.
|
||||
|
||||
All data comes from existing endpoints: /api/metrics, /api/snapshots,
|
||||
/api/stage-times, /api/alerts, /api/fix-rates.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shared_ui import render_page
|
||||
|
||||
|
||||
def render_ops_page(metrics: dict, snapshots: list, changes: list,
|
||||
vital_signs: dict, now: datetime) -> str:
|
||||
"""Render the Pipeline Operations page."""
|
||||
|
||||
# --- Prepare chart data ---
|
||||
timestamps = [s["ts"] for s in snapshots]
|
||||
throughput_data = [s.get("throughput_1h", 0) for s in snapshots]
|
||||
approval_data = [(s.get("approval_rate") or 0) * 100 for s in snapshots]
|
||||
open_prs_data = [s.get("open_prs", 0) for s in snapshots]
|
||||
merged_data = [s.get("merged_total", 0) for s in snapshots]
|
||||
|
||||
rej_wiki = [s.get("rejection_broken_wiki_links", 0) for s in snapshots]
|
||||
rej_schema = [s.get("rejection_frontmatter_schema", 0) for s in snapshots]
|
||||
rej_dup = [s.get("rejection_near_duplicate", 0) for s in snapshots]
|
||||
rej_conf = [s.get("rejection_confidence", 0) for s in snapshots]
|
||||
rej_other = [s.get("rejection_other", 0) for s in snapshots]
|
||||
|
||||
# origin_agent/origin_human removed — replaced by /api/growth chart
|
||||
|
||||
annotations_js = json.dumps([
|
||||
{
|
||||
"type": "line", "xMin": c["ts"], "xMax": c["ts"],
|
||||
"borderColor": "#d29922" if c["type"] == "prompt" else "#58a6ff",
|
||||
"borderWidth": 1, "borderDash": [4, 4],
|
||||
"label": {"display": True, "content": f"{c['type']}: {c.get('to', '?')}",
|
||||
"position": "start", "backgroundColor": "#161b22",
|
||||
"color": "#8b949e", "font": {"size": 10}},
|
||||
}
|
||||
for c in changes
|
||||
])
|
||||
|
||||
# --- Status helpers ---
|
||||
sm = metrics["status_map"]
|
||||
ar = metrics["approval_rate"]
|
||||
ar_color = "green" if ar > 0.5 else ("yellow" if ar > 0.2 else "red")
|
||||
fr_color = "green" if metrics["fix_rate"] > 0.3 else ("yellow" if metrics["fix_rate"] > 0.1 else "red")
|
||||
|
||||
vs_review = vital_signs["review_throughput"]
|
||||
vs_status_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_review["status"], "yellow")
|
||||
|
||||
# --- Rejection reasons table ---
|
||||
reason_rows = "".join(
|
||||
f'<tr><td><code>{r["tag"]}</code></td><td>{r["unique_prs"]}</td>'
|
||||
f'<td style="color:#8b949e">{r["count"]}</td></tr>'
|
||||
for r in metrics["rejection_reasons"]
|
||||
)
|
||||
|
||||
# --- Breaker rows ---
|
||||
breaker_rows = ""
|
||||
for name, info in metrics["breakers"].items():
|
||||
state = info["state"]
|
||||
color = "green" if state == "closed" else ("red" if state == "open" else "yellow")
|
||||
age = f'{info.get("age_s", "?")}s ago' if "age_s" in info else "-"
|
||||
breaker_rows += f'<tr><td>{name}</td><td class="{color}">{state}</td><td>{info["failures"]}</td><td>{age}</td></tr>'
|
||||
|
||||
# --- Funnel ---
|
||||
funnel = vital_signs["funnel"]
|
||||
|
||||
# --- Queue staleness ---
|
||||
qs = vital_signs.get("queue_staleness", {})
|
||||
stale_count = qs.get("stale_count", 0)
|
||||
stale_status = qs.get("status", "healthy")
|
||||
stale_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(stale_status, "")
|
||||
|
||||
body = f"""
|
||||
<!-- Hero Cards -->
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">Throughput</div>
|
||||
<div class="value">{metrics["throughput_1h"]}<span style="font-size:14px;color:#8b949e">/hr</span></div>
|
||||
<div class="detail">merged last hour</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Approval Rate (24h)</div>
|
||||
<div class="value {ar_color}">{ar:.1%}</div>
|
||||
<div class="detail">{metrics["approved_24h"]}/{metrics["evaluated_24h"]} evaluated</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Review Backlog</div>
|
||||
<div class="value {vs_status_color}">{vs_review["backlog"]}</div>
|
||||
<div class="detail">{vs_review["open_prs"]} open + {vs_review["reviewing_prs"]} reviewing + {vs_review["approved_waiting"]} approved</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Merged Total</div>
|
||||
<div class="value green">{sm.get("merged", 0)}</div>
|
||||
<div class="detail">{sm.get("closed", 0)} closed</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Fix Success</div>
|
||||
<div class="value {fr_color}">{metrics["fix_rate"]:.1%}</div>
|
||||
<div class="detail">{metrics["fix_succeeded"]}/{metrics["fix_attempted"]} fixed</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Time to Merge</div>
|
||||
<div class="value">{f"{metrics['median_ttm_minutes']:.0f}" if metrics["median_ttm_minutes"] else "—"}<span style="font-size:14px;color:#8b949e">min</span></div>
|
||||
<div class="detail">median (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Banner (loaded via JS) -->
|
||||
<div id="alert-banner"></div>
|
||||
|
||||
<!-- Pipeline Funnel -->
|
||||
<div class="section">
|
||||
<div class="section-title">Pipeline Funnel</div>
|
||||
<div class="funnel">
|
||||
<div class="funnel-step"><div class="num">{funnel["sources_total"]}</div><div class="lbl">Sources</div></div>
|
||||
<div class="funnel-arrow">→</div>
|
||||
<div class="funnel-step"><div class="num" style="color:#f0883e">{funnel["sources_queued"]}</div><div class="lbl">In Queue</div></div>
|
||||
<div class="funnel-arrow">→</div>
|
||||
<div class="funnel-step"><div class="num">{funnel["sources_extracted"]}</div><div class="lbl">Extracted</div></div>
|
||||
<div class="funnel-arrow">→</div>
|
||||
<div class="funnel-step"><div class="num">{funnel["prs_total"]}</div><div class="lbl">PRs Created</div></div>
|
||||
<div class="funnel-arrow">→</div>
|
||||
<div class="funnel-step"><div class="num green">{funnel["prs_merged"]}</div><div class="lbl">Merged</div></div>
|
||||
<div class="funnel-arrow">→</div>
|
||||
<div class="funnel-step"><div class="num blue">{funnel["conversion_rate"]:.1%}</div><div class="lbl">Conversion</div></div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px;color:#8b949e">
|
||||
Queue staleness: <span class="{stale_color}">{stale_count} stale</span>
|
||||
{f'(oldest: {qs.get("oldest_age_days", "?")}d)' if stale_count > 0 else ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage Dwell Times (loaded via JS) -->
|
||||
<div class="section">
|
||||
<div class="section-title">Stage Dwell Times</div>
|
||||
<div id="stage-times-container" class="grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div id="no-chart-data" class="card" style="text-align:center;padding:40px;margin:16px 0;display:none">
|
||||
<p style="color:#8b949e">No time-series data yet.</p>
|
||||
</div>
|
||||
<div id="chart-section">
|
||||
<div class="row">
|
||||
<div class="chart-container">
|
||||
<h2>Throughput & Approval Rate</h2>
|
||||
<canvas id="throughputChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h2>Rejection Reasons Over Time</h2>
|
||||
<canvas id="rejectionChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="chart-container">
|
||||
<h2>PR Backlog</h2>
|
||||
<canvas id="backlogChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h2>Cumulative Growth</h2>
|
||||
<canvas id="growthChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PR Trace Lookup -->
|
||||
<div class="section">
|
||||
<div class="section-title">PR Trace Lookup</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input id="trace-pr-input" type="number" placeholder="Enter PR number"
|
||||
style="background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:8px 12px;border-radius:6px;width:180px;font-size:14px">
|
||||
<button onclick="loadTrace()" style="background:#238636;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">Trace</button>
|
||||
</div>
|
||||
<div id="trace-result" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tables -->
|
||||
<div class="row">
|
||||
<div class="section">
|
||||
<div class="section-title">Top Rejection Reasons (24h)</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Issue</th><th>PRs</th><th style="color:#8b949e">Events</th></tr>
|
||||
{reason_rows if reason_rows else "<tr><td colspan='3' style='color:#8b949e'>No rejections in 24h</td></tr>"}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Circuit Breakers</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Stage</th><th>State</th><th>Failures</th><th>Last Success</th></tr>
|
||||
{breaker_rows if breaker_rows else "<tr><td colspan='4' style='color:#8b949e'>No breaker data</td></tr>"}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
scripts = f"""<script>
|
||||
const timestamps = {json.dumps(timestamps)};
|
||||
|
||||
// --- Alerts banner ---
|
||||
fetch('/api/alerts')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
if (data.alerts && data.alerts.length > 0) {{
|
||||
const critical = data.alerts.filter(a => a.severity === 'critical');
|
||||
const warning = data.alerts.filter(a => a.severity === 'warning');
|
||||
let html = '';
|
||||
if (critical.length > 0) {{
|
||||
html += '<div class="alert-banner alert-critical">' +
|
||||
critical.map(a => '!! ' + esc(a.title)).join('<br>') + '</div>';
|
||||
}}
|
||||
if (warning.length > 0) {{
|
||||
html += '<div class="alert-banner alert-warning">' +
|
||||
warning.map(a => '! ' + esc(a.title)).join('<br>') + '</div>';
|
||||
}}
|
||||
document.getElementById('alert-banner').innerHTML = html;
|
||||
}}
|
||||
}}).catch(() => {{}});
|
||||
|
||||
// --- Stage dwell times ---
|
||||
fetch('/api/stage-times?hours=24')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
const container = document.getElementById('stage-times-container');
|
||||
const stages = data.stages || {{}};
|
||||
if (Object.keys(stages).length === 0) {{
|
||||
container.innerHTML = '<div class="card" style="grid-column:1/-1;text-align:center;color:#8b949e">No stage timing data yet</div>';
|
||||
return;
|
||||
}}
|
||||
let html = '';
|
||||
for (const [label, info] of Object.entries(stages)) {{
|
||||
const color = info.median_minutes < 5 ? 'green' : info.median_minutes < 30 ? 'yellow' : 'red';
|
||||
html += '<div class="card"><div class="label">' + esc(label) + '</div>' +
|
||||
'<div class="value ' + color + '">' + info.median_minutes.toFixed(1) + '<span style="font-size:14px;color:#8b949e">min</span></div>' +
|
||||
'<div class="detail">median (' + info.count + ' PRs)' +
|
||||
(info.p90_minutes ? ' · p90: ' + info.p90_minutes.toFixed(1) + 'min' : '') +
|
||||
'</div></div>';
|
||||
}}
|
||||
container.innerHTML = html;
|
||||
}}).catch(() => {{}});
|
||||
|
||||
// --- Time-series charts ---
|
||||
if (timestamps.length === 0) {{
|
||||
document.getElementById('chart-section').style.display = 'none';
|
||||
document.getElementById('no-chart-data').style.display = 'block';
|
||||
}} else {{
|
||||
|
||||
const throughputData = {json.dumps(throughput_data)};
|
||||
const approvalData = {json.dumps(approval_data)};
|
||||
const openPrsData = {json.dumps(open_prs_data)};
|
||||
const mergedData = {json.dumps(merged_data)};
|
||||
const rejWiki = {json.dumps(rej_wiki)};
|
||||
const rejSchema = {json.dumps(rej_schema)};
|
||||
const rejDup = {json.dumps(rej_dup)};
|
||||
const rejConf = {json.dumps(rej_conf)};
|
||||
const rejOther = {json.dumps(rej_other)};
|
||||
const annotations = {annotations_js};
|
||||
|
||||
new Chart(document.getElementById('throughputChart'), {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
labels: timestamps,
|
||||
datasets: [
|
||||
{{ label: 'Throughput/hr', data: throughputData, borderColor: '#58a6ff', backgroundColor: 'rgba(88,166,255,0.1)', fill: true, tension: 0.3, yAxisID: 'y', pointRadius: 1 }},
|
||||
{{ label: 'Approval %', data: approvalData, borderColor: '#3fb950', borderDash: [4,2], tension: 0.3, yAxisID: 'y1', pointRadius: 1 }},
|
||||
],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
interaction: {{ mode: 'index', intersect: false }},
|
||||
scales: {{
|
||||
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||||
y: {{ position: 'left', title: {{ display: true, text: 'PRs/hr' }}, min: 0 }},
|
||||
y1: {{ position: 'right', title: {{ display: true, text: 'Approval %' }}, min: 0, max: 100, grid: {{ drawOnChartArea: false }} }},
|
||||
}},
|
||||
plugins: {{ annotation: {{ annotations }}, legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||||
}},
|
||||
}});
|
||||
|
||||
new Chart(document.getElementById('rejectionChart'), {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
labels: timestamps,
|
||||
datasets: [
|
||||
{{ label: 'Wiki Links', data: rejWiki, borderColor: '#f85149', backgroundColor: 'rgba(248,81,73,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||||
{{ label: 'Schema', data: rejSchema, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||||
{{ label: 'Duplicate', data: rejDup, borderColor: '#8b949e', backgroundColor: 'rgba(139,148,158,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||||
{{ label: 'Confidence', data: rejConf, borderColor: '#bc8cff', backgroundColor: 'rgba(188,140,255,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||||
{{ label: 'Other', data: rejOther, borderColor: '#6e7681', backgroundColor: 'rgba(110,118,129,0.15)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||||
],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
scales: {{
|
||||
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||||
y: {{ stacked: true, min: 0, title: {{ display: true, text: 'Count (24h)' }} }},
|
||||
}},
|
||||
plugins: {{ annotation: {{ annotations }}, legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||||
}},
|
||||
}});
|
||||
|
||||
new Chart(document.getElementById('backlogChart'), {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
labels: timestamps,
|
||||
datasets: [
|
||||
{{ label: 'Open PRs', data: openPrsData, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.15)', fill: true, tension: 0.3, pointRadius: 1 }},
|
||||
{{ label: 'Merged (total)', data: mergedData, borderColor: '#3fb950', tension: 0.3, pointRadius: 1 }},
|
||||
],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
scales: {{
|
||||
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||||
y: {{ min: 0, title: {{ display: true, text: 'PRs' }} }},
|
||||
}},
|
||||
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||||
}},
|
||||
}});
|
||||
|
||||
}} // end if timestamps
|
||||
|
||||
// Growth chart loaded async from /api/growth (independent of snapshots)
|
||||
fetch('/api/growth?days=90')
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
if (!data.dates || data.dates.length === 0) return;
|
||||
new Chart(document.getElementById('growthChart'), {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
labels: data.dates,
|
||||
datasets: [
|
||||
{{ label: 'Sources', data: data.sources, borderColor: '#58a6ff', backgroundColor: 'rgba(88,166,255,0.1)', fill: true, tension: 0.3, pointRadius: 1 }},
|
||||
{{ label: 'PRs Created', data: data.prs, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.1)', fill: false, tension: 0.3, pointRadius: 1 }},
|
||||
{{ label: 'Merged', data: data.merged, borderColor: '#3fb950', backgroundColor: 'rgba(63,185,80,0.1)', fill: false, tension: 0.3, pointRadius: 1 }},
|
||||
],
|
||||
}},
|
||||
options: {{
|
||||
responsive: true,
|
||||
interaction: {{ mode: 'index', intersect: false }},
|
||||
scales: {{
|
||||
x: {{ type: 'time', time: {{ unit: 'day', displayFormats: {{ day: 'MMM d' }} }}, grid: {{ display: false }} }},
|
||||
y: {{ min: 0, title: {{ display: true, text: 'Cumulative Count' }} }},
|
||||
}},
|
||||
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||||
}},
|
||||
}});
|
||||
}}).catch(() => {{}});
|
||||
|
||||
// --- PR Trace Lookup ---
|
||||
document.getElementById('trace-pr-input').addEventListener('keydown', e => {{ if (e.key === 'Enter') loadTrace(); }});
|
||||
|
||||
function loadTrace() {{
|
||||
const pr = document.getElementById('trace-pr-input').value.trim();
|
||||
const container = document.getElementById('trace-result');
|
||||
if (!pr) {{ container.innerHTML = '<p style="color:#8b949e">Enter a PR number</p>'; return; }}
|
||||
container.innerHTML = '<p style="color:#8b949e">Loading...</p>';
|
||||
|
||||
fetch('/api/trace/' + encodeURIComponent(pr))
|
||||
.then(r => r.json())
|
||||
.then(data => {{
|
||||
if (!data.pr && data.timeline.length === 0) {{
|
||||
container.innerHTML = '<p style="color:#8b949e">No trace found for PR ' + esc(pr) + '</p>';
|
||||
return;
|
||||
}}
|
||||
|
||||
const stageColors = {{
|
||||
ingest: '#58a6ff', validate: '#d29922', evaluate: '#f0883e',
|
||||
merge: '#3fb950', cascade: '#bc8cff', cross_domain: '#79c0ff'
|
||||
}};
|
||||
|
||||
let html = '';
|
||||
|
||||
// PR summary
|
||||
if (data.pr) {{
|
||||
const p = data.pr;
|
||||
html += '<div style="margin-bottom:12px;padding:8px 12px;background:#21262d;border-radius:6px;font-size:13px">' +
|
||||
'<strong>PR #' + esc(String(p.number)) + '</strong> · ' +
|
||||
'<span style="color:' + (p.status === 'merged' ? '#3fb950' : '#d29922') + '">' + esc(p.status) + '</span>' +
|
||||
' · ' + esc(p.domain || 'general') +
|
||||
' · ' + esc(p.agent || '?') +
|
||||
' · ' + esc(p.tier || '?') +
|
||||
' · created ' + esc(p.created_at || '') +
|
||||
(p.merged_at ? ' · merged ' + esc(p.merged_at) : '') +
|
||||
'</div>';
|
||||
}}
|
||||
|
||||
// Timeline
|
||||
if (data.timeline.length > 0) {{
|
||||
html += '<div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:6px;text-transform:uppercase">Timeline</div>';
|
||||
html += '<table style="font-size:12px"><tr><th>Time</th><th>Stage</th><th>Event</th><th>Details</th></tr>';
|
||||
for (const evt of data.timeline) {{
|
||||
const sc = stageColors[evt.stage] || '#8b949e';
|
||||
const detail = evt.detail || {{}};
|
||||
// Show key fields inline, expandable full JSON
|
||||
const keyFields = [];
|
||||
if (detail.issues) keyFields.push('issues: ' + detail.issues.join(', '));
|
||||
if (detail.agent) keyFields.push('agent: ' + detail.agent);
|
||||
if (detail.tier) keyFields.push('tier: ' + detail.tier);
|
||||
if (detail.leo) keyFields.push('leo: ' + detail.leo);
|
||||
if (detail.domain) keyFields.push('domain: ' + detail.domain);
|
||||
if (detail.pass != null) keyFields.push('pass: ' + detail.pass);
|
||||
if (detail.attempt) keyFields.push('attempt: ' + detail.attempt);
|
||||
const summary = keyFields.length > 0 ? esc(keyFields.join(' | ')) : '';
|
||||
const fullJson = JSON.stringify(detail, null, 2);
|
||||
const detailId = 'trace-detail-' + Math.random().toString(36).substr(2, 6);
|
||||
|
||||
html += '<tr>' +
|
||||
'<td style="white-space:nowrap;color:#8b949e">' + esc(evt.timestamp) + '</td>' +
|
||||
'<td><span style="color:' + sc + ';font-weight:600">' + esc(evt.stage) + '</span></td>' +
|
||||
'<td>' + esc(evt.event) + '</td>' +
|
||||
'<td>' + summary +
|
||||
(Object.keys(detail).length > 0
|
||||
? ' <a style="color:#58a6ff;cursor:pointer;font-size:11px" onclick="document.getElementById(\\\'' + detailId + '\\\').style.display=document.getElementById(\\\'' + detailId + '\\\').style.display===\\\'none\\\'?\\\'block\\\':\\\'none\\\'">[json]</a>' +
|
||||
'<pre id="' + detailId + '" style="display:none;margin-top:4px;background:#0d1117;padding:6px;border-radius:4px;font-size:11px;overflow-x:auto;max-width:500px">' + esc(fullJson) + '</pre>'
|
||||
: '') +
|
||||
'</td></tr>';
|
||||
}}
|
||||
html += '</table>';
|
||||
}}
|
||||
|
||||
// Reviews
|
||||
if (data.reviews && data.reviews.length > 0) {{
|
||||
html += '<div style="font-size:12px;font-weight:600;color:#8b949e;margin:12px 0 6px;text-transform:uppercase">Reviews</div>';
|
||||
html += '<table style="font-size:12px"><tr><th>Claim</th><th>Outcome</th><th>Reviewer</th><th>Reason</th></tr>';
|
||||
for (const rv of data.reviews) {{
|
||||
const outColor = rv.outcome === 'approved' ? '#3fb950' : rv.outcome === 'rejected' ? '#f85149' : '#d29922';
|
||||
html += '<tr>' +
|
||||
'<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis">' + esc(rv.claim_path || '-') + '</td>' +
|
||||
'<td><span class="badge" style="background:' + outColor + '33;color:' + outColor + '">' + esc(rv.outcome || '-') + '</span></td>' +
|
||||
'<td>' + esc(rv.reviewer || '-') + '</td>' +
|
||||
'<td>' + esc(rv.rejection_reason || '') + '</td></tr>';
|
||||
}}
|
||||
html += '</table>';
|
||||
}}
|
||||
|
||||
container.innerHTML = html;
|
||||
}})
|
||||
.catch(err => {{
|
||||
container.innerHTML = '<p style="color:#f85149">Error: ' + esc(err.message) + '</p>';
|
||||
}});
|
||||
}}
|
||||
</script>"""
|
||||
|
||||
return render_page(
|
||||
title="Pipeline Operations",
|
||||
subtitle="Is the machine running?",
|
||||
active_path="/ops",
|
||||
body_html=body,
|
||||
scripts=scripts,
|
||||
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
"""Portfolio dashboard — fixes empty chart by:
|
||||
1. Computing NAV server-side in the history API (not client-side from nulls)
|
||||
2. Only returning dates with valid NAV data
|
||||
3. Showing data points when sparse
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
from html import escape as esc
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
from shared_ui import render_page
|
||||
|
||||
logger = logging.getLogger("argus.portfolio")
|
||||
|
||||
CSS = """
|
||||
.hero-chart { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
.hero-chart h2 { color: #c9d1d9; font-size: 18px; margin-bottom: 12px; }
|
||||
.range-btns { display: flex; gap: 4px; margin-bottom: 12px; }
|
||||
.range-btn { background: #21262d; border: 1px solid #30363d; color: #8b949e; padding: 5px 14px;
|
||||
border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.range-btn.active { background: #1f6feb33; border-color: #58a6ff; color: #58a6ff; }
|
||||
.ptable-wrap { overflow-x: auto; margin-top: 20px; }
|
||||
.ptable { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.ptable th { background: #161b22; color: #8b949e; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; padding: 10px 12px; text-align: right; border-bottom: 1px solid #30363d;
|
||||
cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
.ptable th:first-child { text-align: left; position: sticky; left: 0; background: #161b22; z-index: 1; }
|
||||
.ptable th:hover { color: #c9d1d9; }
|
||||
.ptable th.sorted-asc::after { content: ' \\25B2'; font-size: 9px; }
|
||||
.ptable th.sorted-desc::after { content: ' \\25BC'; font-size: 9px; }
|
||||
.ptable td { padding: 10px 12px; text-align: right; border-bottom: 1px solid #21262d; color: #c9d1d9; }
|
||||
.ptable td:first-child { text-align: left; position: sticky; left: 0; background: #0d1117; z-index: 1; font-weight: 600; }
|
||||
.ptable tr:hover td { background: #161b22; }
|
||||
.ptable tr:hover td:first-child { background: #161b22; }
|
||||
.summary-row td { font-weight: 700; border-top: 2px solid #30363d; background: #161b22 !important; }
|
||||
.premium { color: #f85149; }
|
||||
.discount { color: #3fb950; }
|
||||
.near-nav { color: #d29922; }
|
||||
"""
|
||||
|
||||
|
||||
def _fmt_usd(v):
|
||||
if v is None:
|
||||
return '\u2014'
|
||||
if abs(v) >= 1_000_000:
|
||||
return f'${v / 1_000_000:.1f}M'
|
||||
if abs(v) >= 1_000:
|
||||
return f'${v / 1_000:.0f}K'
|
||||
return f'${v:,.0f}'
|
||||
|
||||
|
||||
def _fmt_price(v):
|
||||
if v is None:
|
||||
return '\u2014'
|
||||
if v >= 100:
|
||||
return f'${v:,.0f}'
|
||||
if v >= 1:
|
||||
return f'${v:.2f}'
|
||||
if v >= 0.01:
|
||||
return f'${v:.4f}'
|
||||
return f'${v:.6f}'
|
||||
|
||||
|
||||
def _fmt_ratio(v):
|
||||
if v is None or v == 0:
|
||||
return '\u2014'
|
||||
return f'{v:.2f}x'
|
||||
|
||||
|
||||
def _ratio_class(v):
|
||||
if v is None or v == 0:
|
||||
return ''
|
||||
if v > 1.5:
|
||||
return 'premium'
|
||||
if v < 0.9:
|
||||
return 'discount'
|
||||
if v <= 1.1:
|
||||
return 'near-nav'
|
||||
return ''
|
||||
|
||||
|
||||
def render_portfolio_page(coins: list[dict], now: datetime) -> str:
|
||||
if not coins:
|
||||
body = '<div style="padding:40px;text-align:center;color:#8b949e;">No coin data yet.</div>'
|
||||
return render_page("Portfolio", "Ownership coin portfolio", "/portfolio", body,
|
||||
extra_css=CSS, timestamp=now.strftime("%Y-%m-%d %H:%M UTC"))
|
||||
|
||||
total_mcap = sum(c.get('market_cap_usd') or 0 for c in coins)
|
||||
total_treasury = sum(c.get('treasury_usd') or 0 for c in coins)
|
||||
|
||||
hero_chart = """
|
||||
<div class="hero-chart">
|
||||
<h2>Price / NAV per Token</h2>
|
||||
<div class="range-btns">
|
||||
<button class="range-btn" onclick="setRange(this, 30)">30d</button>
|
||||
<button class="range-btn active" onclick="setRange(this, 90)">90d</button>
|
||||
<button class="range-btn" onclick="setRange(this, 180)">180d</button>
|
||||
<button class="range-btn" onclick="setRange(this, 365)">All</button>
|
||||
</div>
|
||||
<canvas id="ratio-chart" height="320" style="max-height:320px"></canvas>
|
||||
</div>
|
||||
"""
|
||||
|
||||
header = """<div class="ptable-wrap"><table class="ptable" id="coin-table">
|
||||
<thead><tr>
|
||||
<th data-col="name">Coin</th>
|
||||
<th data-col="price">Price</th>
|
||||
<th data-col="nav">NAV / Token</th>
|
||||
<th data-col="ratio">Price / NAV</th>
|
||||
<th data-col="treasury">Treasury</th>
|
||||
<th data-col="mcap">Market Cap</th>
|
||||
</tr></thead><tbody>"""
|
||||
|
||||
rows = ''
|
||||
for c in coins:
|
||||
name = c.get('name', '?')
|
||||
ticker = c.get('ticker', '')
|
||||
price = c.get('price_usd')
|
||||
nav = c.get('nav_per_token')
|
||||
ratio = c.get('price_nav_ratio')
|
||||
treasury = c.get('treasury_usd')
|
||||
mcap = c.get('market_cap_usd')
|
||||
|
||||
label = esc(name)
|
||||
if ticker:
|
||||
label += f' <span style="color:#8b949e;font-size:11px;">{esc(ticker)}</span>'
|
||||
|
||||
rows += f"""<tr>
|
||||
<td>{label}</td>
|
||||
<td>{_fmt_price(price)}</td>
|
||||
<td>{_fmt_price(nav)}</td>
|
||||
<td class="{_ratio_class(ratio)}">{_fmt_ratio(ratio)}</td>
|
||||
<td>{_fmt_usd(treasury)}</td>
|
||||
<td>{_fmt_usd(mcap)}</td>
|
||||
</tr>"""
|
||||
|
||||
rows += f"""<tr class="summary-row">
|
||||
<td>Total ({len(coins)})</td>
|
||||
<td></td><td></td><td></td>
|
||||
<td>{_fmt_usd(total_treasury)}</td>
|
||||
<td>{_fmt_usd(total_mcap)}</td>
|
||||
</tr>"""
|
||||
|
||||
table = header + rows + '</tbody></table></div>'
|
||||
|
||||
scripts = """<script>
|
||||
const COLORS = ['#58a6ff','#3fb950','#f0883e','#d29922','#f85149','#bc8cff','#39d353','#79c0ff','#ff7b72','#a5d6ff'];
|
||||
let chart = null;
|
||||
|
||||
function setRange(btn, days) {
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
loadChart(days);
|
||||
}
|
||||
|
||||
function loadChart(days) {
|
||||
fetch('/api/portfolio/nav-ratios?days=' + days)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const dates = data.dates || [];
|
||||
const series = data.series || {};
|
||||
|
||||
if (dates.length === 0) {
|
||||
if (chart) chart.destroy();
|
||||
chart = null;
|
||||
const ctx = document.getElementById('ratio-chart').getContext('2d');
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('No NAV data yet — accumulating daily snapshots', ctx.canvas.width / 2, 160);
|
||||
return;
|
||||
}
|
||||
|
||||
const sparse = dates.length <= 10;
|
||||
const datasets = [];
|
||||
let i = 0;
|
||||
for (const [name, ratios] of Object.entries(series)) {
|
||||
const hasData = ratios.some(v => v !== null);
|
||||
if (!hasData) { i++; continue; }
|
||||
datasets.push({
|
||||
label: name,
|
||||
data: ratios,
|
||||
borderColor: COLORS[i % COLORS.length],
|
||||
backgroundColor: COLORS[i % COLORS.length] + '33',
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
spanGaps: true,
|
||||
pointRadius: sparse ? 4 : 0,
|
||||
pointHoverRadius: 6,
|
||||
fill: false,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (chart) chart.destroy();
|
||||
const ctx = document.getElementById('ratio-chart').getContext('2d');
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: dates, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b949e', font: { size: 11 }, usePointStyle: true, boxWidth: 8 }, position: 'top' },
|
||||
tooltip: { mode: 'index', intersect: false,
|
||||
callbacks: { label: ctx => ctx.dataset.label + ': ' + (ctx.parsed.y != null ? ctx.parsed.y.toFixed(2) + 'x' : 'n/a') }
|
||||
},
|
||||
annotation: {
|
||||
annotations: {
|
||||
navLine: {
|
||||
type: 'line',
|
||||
yMin: 1, yMax: 1,
|
||||
borderColor: '#3fb95088',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 4],
|
||||
label: {
|
||||
display: true,
|
||||
content: '1.0x = NAV',
|
||||
position: 'end',
|
||||
backgroundColor: '#3fb95033',
|
||||
color: '#3fb950',
|
||||
font: { size: 10 },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { display: false } },
|
||||
y: { ticks: { color: '#8b949e', callback: v => v.toFixed(1) + 'x' }, grid: { color: '#21262d' },
|
||||
suggestedMin: 0 }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Table sorting
|
||||
function sortTable(col) {
|
||||
const table = document.getElementById('coin-table');
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr:not(.summary-row)'));
|
||||
const summaryRow = tbody.querySelector('.summary-row');
|
||||
const th = table.querySelectorAll('th')[col];
|
||||
const asc = th.classList.contains('sorted-asc');
|
||||
table.querySelectorAll('th').forEach(h => h.classList.remove('sorted-asc','sorted-desc'));
|
||||
th.classList.add(asc ? 'sorted-desc' : 'sorted-asc');
|
||||
rows.sort((a, b) => {
|
||||
let va = a.cells[col].textContent.replace(/[$,+%x\\u2014]/g,'').trim();
|
||||
let vb = b.cells[col].textContent.replace(/[$,+%x\\u2014]/g,'').trim();
|
||||
const na = parseFloat(va) || 0, nb = parseFloat(vb) || 0;
|
||||
if (col === 0) return asc ? vb.localeCompare(va) : va.localeCompare(vb);
|
||||
return asc ? na - nb : nb - na;
|
||||
});
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
if (summaryRow) tbody.appendChild(summaryRow);
|
||||
}
|
||||
document.querySelectorAll('#coin-table th').forEach((th, i) => {
|
||||
th.addEventListener('click', () => sortTable(i));
|
||||
});
|
||||
|
||||
loadChart(90);
|
||||
</script>"""
|
||||
|
||||
body = hero_chart + table
|
||||
return render_page("Portfolio", "Ownership coin portfolio", "/portfolio", body,
|
||||
scripts=scripts, extra_css=CSS,
|
||||
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"))
|
||||
|
||||
|
||||
# ── API handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_db(request):
|
||||
return request.app["_portfolio_conn"]()
|
||||
|
||||
|
||||
def _compute_nav(row):
|
||||
"""Compute NAV per token and Price/NAV ratio from a snapshot row dict."""
|
||||
treas = (row.get('treasury_multisig_usd') or 0) + (row.get('lp_usdc_total') or 0)
|
||||
adj = row.get('adjusted_circulating_supply') or 0
|
||||
price = row.get('price_usd') or 0
|
||||
nav = treas / adj if adj > 0 else 0
|
||||
ratio = price / nav if nav > 0 else 0
|
||||
return treas, nav, ratio
|
||||
|
||||
|
||||
async def handle_portfolio_page(request):
|
||||
conn = _get_db(request)
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM coin_snapshots
|
||||
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM coin_snapshots)
|
||||
ORDER BY market_cap_usd DESC
|
||||
""").fetchall()
|
||||
coins = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
treas, nav, ratio = _compute_nav(d)
|
||||
d['treasury_usd'] = treas
|
||||
d['nav_per_token'] = nav
|
||||
d['price_nav_ratio'] = ratio
|
||||
coins.append(d)
|
||||
now = datetime.now(timezone.utc)
|
||||
html = render_portfolio_page(coins, now)
|
||||
return web.Response(text=html, content_type='text/html')
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def handle_nav_ratios(request):
|
||||
"""Server-side computed NAV ratios — only returns dates with valid data."""
|
||||
conn = _get_db(request)
|
||||
try:
|
||||
try:
|
||||
days = min(int(request.query.get('days', '90')), 365)
|
||||
except (ValueError, TypeError):
|
||||
days = 90
|
||||
rows = conn.execute("""
|
||||
SELECT name, snapshot_date, price_usd, treasury_multisig_usd,
|
||||
lp_usdc_total, adjusted_circulating_supply
|
||||
FROM coin_snapshots
|
||||
WHERE snapshot_date >= date('now', ? || ' days')
|
||||
AND adjusted_circulating_supply IS NOT NULL
|
||||
AND adjusted_circulating_supply > 0
|
||||
ORDER BY name, snapshot_date
|
||||
""", (f'-{days}',)).fetchall()
|
||||
|
||||
coin_ratios = {}
|
||||
all_dates = set()
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
name = d['name']
|
||||
date = d['snapshot_date']
|
||||
_, nav, ratio = _compute_nav(d)
|
||||
if nav > 0 and ratio > 0:
|
||||
if name not in coin_ratios:
|
||||
coin_ratios[name] = {}
|
||||
coin_ratios[name][date] = round(ratio, 3)
|
||||
all_dates.add(date)
|
||||
|
||||
sorted_dates = sorted(all_dates)
|
||||
series = {}
|
||||
for name, date_map in coin_ratios.items():
|
||||
series[name] = [date_map.get(d) for d in sorted_dates]
|
||||
|
||||
return web.json_response({
|
||||
'dates': sorted_dates,
|
||||
'series': series,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def handle_portfolio_history(request):
|
||||
conn = _get_db(request)
|
||||
try:
|
||||
try:
|
||||
days = min(int(request.query.get('days', '90')), 365)
|
||||
except (ValueError, TypeError):
|
||||
days = 90
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM coin_snapshots
|
||||
WHERE snapshot_date >= date('now', ? || ' days')
|
||||
ORDER BY name, snapshot_date
|
||||
""", (f'-{days}',)).fetchall()
|
||||
history = {}
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
key = d['name']
|
||||
if key not in history:
|
||||
history[key] = []
|
||||
history[key].append(d)
|
||||
return web.json_response({'history': history})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def handle_portfolio_latest(request):
|
||||
conn = _get_db(request)
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM coin_snapshots
|
||||
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM coin_snapshots)
|
||||
ORDER BY market_cap_usd DESC
|
||||
""").fetchall()
|
||||
coins = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
treas, nav, ratio = _compute_nav(d)
|
||||
d['treasury_usd'] = treas
|
||||
d['nav_per_token'] = nav
|
||||
d['price_nav_ratio'] = ratio
|
||||
coins.append(d)
|
||||
return web.json_response({'coins': coins, 'date': coins[0]['snapshot_date'] if coins else None})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_portfolio_routes(app, get_conn):
|
||||
app["_portfolio_conn"] = get_conn
|
||||
app.router.add_get("/portfolio", handle_portfolio_page)
|
||||
app.router.add_get("/api/portfolio/nav-ratios", handle_nav_ratios)
|
||||
app.router.add_get("/api/portfolio/history", handle_portfolio_history)
|
||||
app.router.add_get("/api/portfolio/latest", handle_portfolio_latest)
|
||||
|
|
@ -1,564 +0,0 @@
|
|||
"""PR Lifecycle dashboard — single-page view of every PR through the pipeline.
|
||||
|
||||
Sortable table: PR#, summary, claims, domain, outcome, evals, evaluator, cost, date.
|
||||
Click any row to expand: timeline, claim list, issues summary.
|
||||
Hero cards: total PRs, merge rate, median eval rounds, total claims, total cost.
|
||||
|
||||
Data sources: prs table, audit_log (eval rounds), review_records.
|
||||
Owner: Ship
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shared_ui import render_page
|
||||
|
||||
|
||||
EXTRA_CSS = """
|
||||
.page-content { max-width: 1600px !important; }
|
||||
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.filters select, .filters input {
|
||||
background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
|
||||
border-radius: 6px; padding: 6px 10px; font-size: 12px; }
|
||||
.filters select:focus, .filters input:focus { border-color: #58a6ff; outline: none; }
|
||||
.pr-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
||||
.pr-table th:nth-child(1) { width: 50px; } /* PR# */
|
||||
.pr-table th:nth-child(2) { width: 30%; } /* Summary */
|
||||
.pr-table th:nth-child(3) { width: 50px; } /* Claims */
|
||||
.pr-table th:nth-child(4) { width: 12%; } /* Domain */
|
||||
.pr-table th:nth-child(5) { width: 10%; } /* Outcome */
|
||||
.pr-table th:nth-child(6) { width: 50px; } /* Evals */
|
||||
.pr-table th:nth-child(7) { width: 16%; } /* Evaluator */
|
||||
.pr-table th:nth-child(8) { width: 70px; } /* Cost */
|
||||
.pr-table th:nth-child(9) { width: 90px; } /* Date */
|
||||
.pr-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 8px 6px; }
|
||||
.pr-table td:nth-child(2) { white-space: normal; overflow: visible; line-height: 1.4; }
|
||||
.pr-table th { cursor: pointer; user-select: none; position: relative; padding: 8px 18px 8px 6px; }
|
||||
.pr-table th:hover { color: #58a6ff; }
|
||||
.pr-table th .sort-arrow { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); font-size: 10px; opacity: 0.5; }
|
||||
.pr-table th.sorted .sort-arrow { opacity: 1; color: #58a6ff; }
|
||||
.pr-table tr { cursor: pointer; transition: background 0.1s; }
|
||||
.pr-table tbody tr:hover { background: #161b22; }
|
||||
.pr-table .outcome-merged { color: #3fb950; }
|
||||
.pr-table .outcome-closed { color: #f85149; }
|
||||
.pr-table .outcome-open { color: #d29922; }
|
||||
.pr-table .tier-deep { color: #bc8cff; font-weight: 600; }
|
||||
.pr-table .tier-standard { color: #58a6ff; }
|
||||
.pr-table .tier-light { color: #8b949e; }
|
||||
.pr-table .pr-link { color: #58a6ff; text-decoration: none; }
|
||||
.pr-table .pr-link:hover { text-decoration: underline; }
|
||||
.pr-table td .summary-text { font-size: 12px; color: #c9d1d9; }
|
||||
.pr-table td .review-snippet { font-size: 11px; color: #f85149; margin-top: 2px; opacity: 0.8; }
|
||||
.pr-table td .model-tag { font-size: 9px; color: #6e7681; background: #21262d; border-radius: 3px; padding: 1px 4px; display: inline-block; margin: 1px 0; }
|
||||
.pr-table td .expand-chevron { display: inline-block; width: 12px; color: #484f58; font-size: 10px; transition: transform 0.2s; }
|
||||
.pr-table tr.expanded .expand-chevron { transform: rotate(90deg); color: #58a6ff; }
|
||||
.pr-table td .cost-val { font-size: 12px; color: #8b949e; }
|
||||
.pr-table td .claims-count { font-size: 13px; color: #c9d1d9; text-align: center; }
|
||||
.pr-table td .evals-count { font-size: 13px; text-align: center; }
|
||||
.trace-panel { background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 16px; margin: 4px 0 8px 0; font-size: 12px; display: none; }
|
||||
.trace-panel.open { display: block; }
|
||||
.trace-panel .section-title { color: #58a6ff; font-size: 12px; font-weight: 600; margin: 12px 0 6px; }
|
||||
.trace-panel .section-title:first-child { margin-top: 0; }
|
||||
.trace-panel .claim-list { list-style: none; padding: 0; margin: 0; }
|
||||
.trace-panel .claim-list li { padding: 4px 0; border-bottom: 1px solid #21262d; color: #c9d1d9; font-size: 12px; }
|
||||
.trace-panel .claim-list li:last-child { border-bottom: none; }
|
||||
.trace-panel .issues-box { background: #1c1017; border: 1px solid #f8514930; border-radius: 6px;
|
||||
padding: 8px 12px; margin: 4px 0; font-size: 12px; color: #f85149; }
|
||||
.trace-timeline { list-style: none; padding: 0; }
|
||||
.trace-timeline li { padding: 4px 0; border-left: 2px solid #30363d; padding-left: 12px; margin-left: 8px; }
|
||||
.trace-timeline li .ts { color: #484f58; font-size: 11px; }
|
||||
.trace-timeline li .ev { font-weight: 600; }
|
||||
.trace-timeline li.ev-approved .ev { color: #3fb950; }
|
||||
.trace-timeline li.ev-rejected .ev { color: #f85149; }
|
||||
.trace-timeline li.ev-changes .ev { color: #d29922; }
|
||||
.review-text { background: #161b22; padding: 8px 12px; border-radius: 4px;
|
||||
margin: 4px 0; white-space: pre-wrap; font-size: 11px; color: #8b949e; max-height: 200px; overflow-y: auto; }
|
||||
.eval-chain { background: #161b22; border-radius: 6px; padding: 8px 12px; margin: 4px 0 8px;
|
||||
font-size: 12px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.eval-chain .step { display: flex; align-items: center; gap: 4px; }
|
||||
.eval-chain .step-label { color: #8b949e; font-size: 11px; }
|
||||
.eval-chain .step-model { color: #c9d1d9; font-size: 11px; font-weight: 600; }
|
||||
.eval-chain .arrow { color: #484f58; }
|
||||
.pagination { display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 16px; }
|
||||
.pagination button { background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
|
||||
border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 12px; }
|
||||
.pagination button:hover { border-color: #58a6ff; }
|
||||
.pagination button:disabled { opacity: 0.4; cursor: default; }
|
||||
.pagination .page-info { color: #8b949e; font-size: 12px; }
|
||||
"""
|
||||
|
||||
|
||||
def render_prs_page(now: datetime) -> str:
|
||||
"""Render the PR lifecycle page. All data loaded client-side via /api/pr-lifecycle."""
|
||||
|
||||
body = """
|
||||
<!-- Hero cards (populated by JS) -->
|
||||
<div class="grid" id="hero-cards">
|
||||
<div class="card"><div class="label">Total PRs</div><div class="value blue" id="kpi-total">--</div><div class="detail" id="kpi-total-detail"></div></div>
|
||||
<div class="card"><div class="label">Merge Rate</div><div class="value green" id="kpi-merge-rate">--</div><div class="detail" id="kpi-merge-detail"></div></div>
|
||||
<div class="card"><div class="label">Median Eval Rounds</div><div class="value" id="kpi-rounds">--</div><div class="detail" id="kpi-rounds-detail"></div></div>
|
||||
<div class="card"><div class="label">Total Claims</div><div class="value blue" id="kpi-claims">--</div><div class="detail" id="kpi-claims-detail"></div></div>
|
||||
<div class="card"><div class="label">Est. Cost</div><div class="value" id="kpi-cost">--</div><div class="detail" id="kpi-cost-detail"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<select id="filter-domain"><option value="">All Domains</option></select>
|
||||
<select id="filter-outcome">
|
||||
<option value="">All Outcomes</option>
|
||||
<option value="merged">Merged</option>
|
||||
<option value="closed">Rejected</option>
|
||||
<option value="open">Open</option>
|
||||
</select>
|
||||
<select id="filter-tier">
|
||||
<option value="">All Tiers</option>
|
||||
<option value="DEEP">Deep</option>
|
||||
<option value="STANDARD">Standard</option>
|
||||
<option value="LIGHT">Light</option>
|
||||
</select>
|
||||
<select id="filter-days">
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30" selected>Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
<option value="0">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- PR table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="pr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="number">PR# <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="summary">Summary <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="claims_count">Claims <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="domain">Domain <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="status">Outcome <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="eval_rounds">Evals <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="evaluator">Evaluator <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="est_cost">Cost <span class="sort-arrow">▲</span></th>
|
||||
<th data-col="created_at">Date <span class="sort-arrow">▲</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pr-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination">
|
||||
<button id="pg-prev" disabled>« Prev</button>
|
||||
<span class="page-info" id="pg-info">--</span>
|
||||
<button id="pg-next" disabled>Next »</button>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Use single-quoted JS strings throughout to avoid Python/HTML escaping issues
|
||||
scripts = """<script>
|
||||
const PAGE_SIZE = 50;
|
||||
const FORGEJO = 'https://git.livingip.xyz/teleo/teleo-codex/pulls/';
|
||||
let allData = [];
|
||||
let filtered = [];
|
||||
let sortCol = 'number';
|
||||
let sortAsc = false;
|
||||
let page = 0;
|
||||
let expandedPr = null;
|
||||
|
||||
function loadData() {
|
||||
var days = document.getElementById('filter-days').value;
|
||||
var url = '/api/pr-lifecycle' + (days !== '0' ? '?days=' + days : '?days=9999');
|
||||
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
|
||||
allData = data.prs || [];
|
||||
populateFilters(allData);
|
||||
updateKPIs(data);
|
||||
applyFilters();
|
||||
}).catch(function() {
|
||||
document.getElementById('pr-tbody').innerHTML =
|
||||
'<tr><td colspan="9" style="text-align:center;color:#f85149;">Failed to load data</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
function populateFilters(prs) {
|
||||
var domains = [], seenD = {};
|
||||
prs.forEach(function(p) {
|
||||
if (p.domain && !seenD[p.domain]) { seenD[p.domain] = 1; domains.push(p.domain); }
|
||||
});
|
||||
domains.sort();
|
||||
var domSel = document.getElementById('filter-domain');
|
||||
var curDom = domSel.value;
|
||||
domSel.innerHTML = '<option value="">All Domains</option>' +
|
||||
domains.map(function(d) { return '<option value="' + esc(d) + '">' + esc(d) + '</option>'; }).join('');
|
||||
domSel.value = curDom;
|
||||
}
|
||||
|
||||
function updateKPIs(data) {
|
||||
document.getElementById('kpi-total').textContent = fmtNum(data.total);
|
||||
document.getElementById('kpi-total-detail').textContent =
|
||||
fmtNum(data.merged) + ' merged, ' + fmtNum(data.closed) + ' rejected';
|
||||
|
||||
var rate = data.total > 0 ? data.merged / (data.merged + data.closed) : 0;
|
||||
document.getElementById('kpi-merge-rate').textContent = fmtPct(rate);
|
||||
document.getElementById('kpi-merge-detail').textContent = fmtNum(data.open) + ' open';
|
||||
|
||||
document.getElementById('kpi-rounds').textContent =
|
||||
data.median_rounds != null ? data.median_rounds.toFixed(1) : '--';
|
||||
document.getElementById('kpi-rounds-detail').textContent =
|
||||
data.max_rounds != null ? 'max: ' + data.max_rounds : '';
|
||||
|
||||
var totalClaims = 0, mergedClaims = 0;
|
||||
var totalCost = 0;
|
||||
var actualCount = 0, estCount = 0;
|
||||
(data.prs || []).forEach(function(p) {
|
||||
totalClaims += (p.claims_count || 1);
|
||||
if (p.status === 'merged') mergedClaims += (p.claims_count || 1);
|
||||
totalCost += (p.cost || 0);
|
||||
if (p.cost_is_actual) actualCount++; else estCount++;
|
||||
});
|
||||
document.getElementById('kpi-claims').textContent = fmtNum(totalClaims);
|
||||
document.getElementById('kpi-claims-detail').textContent = fmtNum(mergedClaims) + ' merged';
|
||||
|
||||
// Show actual DB total if available, otherwise sum from PRs
|
||||
var costLabel = '';
|
||||
if (data.actual_total_cost > 0) {
|
||||
document.getElementById('kpi-cost').textContent = '$' + data.actual_total_cost.toFixed(2);
|
||||
costLabel = 'from costs table';
|
||||
} else if (actualCount > 0) {
|
||||
document.getElementById('kpi-cost').textContent = '$' + totalCost.toFixed(2);
|
||||
costLabel = actualCount + ' actual, ' + estCount + ' est.';
|
||||
} else {
|
||||
document.getElementById('kpi-cost').textContent = '$' + totalCost.toFixed(2);
|
||||
costLabel = 'ALL ESTIMATED';
|
||||
}
|
||||
var costPerClaim = totalClaims > 0 ? totalCost / totalClaims : 0;
|
||||
document.getElementById('kpi-cost-detail').textContent =
|
||||
'$' + costPerClaim.toFixed(3) + '/claim \u00b7 ' + costLabel;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var dom = document.getElementById('filter-domain').value;
|
||||
var out = document.getElementById('filter-outcome').value;
|
||||
var tier = document.getElementById('filter-tier').value;
|
||||
|
||||
filtered = allData.filter(function(p) {
|
||||
if (dom && p.domain !== dom) return false;
|
||||
if (out && p.status !== out) return false;
|
||||
if (tier && p.tier !== tier) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
sortData();
|
||||
page = 0;
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function sortData() {
|
||||
filtered.sort(function(a, b) {
|
||||
var va = a[sortCol], vb = b[sortCol];
|
||||
if (va == null) va = '';
|
||||
if (vb == null) vb = '';
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortAsc ? va - vb : vb - va;
|
||||
}
|
||||
va = String(va).toLowerCase();
|
||||
vb = String(vb).toLowerCase();
|
||||
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return '';
|
||||
return s.length > n ? s.substring(0, n) + '...' : s;
|
||||
}
|
||||
|
||||
function shortModel(m) {
|
||||
if (!m) return '';
|
||||
// Shorten model names for display
|
||||
if (m.indexOf('gemini-2.5-flash') !== -1) return 'Gemini Flash';
|
||||
if (m.indexOf('claude-sonnet') !== -1 || m.indexOf('sonnet-4') !== -1) return 'Sonnet';
|
||||
if (m.indexOf('claude-opus') !== -1 || m.indexOf('opus') !== -1) return 'Opus';
|
||||
if (m.indexOf('haiku') !== -1) return 'Haiku';
|
||||
if (m.indexOf('gpt-4o') !== -1) return 'GPT-4o';
|
||||
// fallback: strip provider prefix
|
||||
var parts = m.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
var tbody = document.getElementById('pr-tbody');
|
||||
var start = page * PAGE_SIZE;
|
||||
var slice = filtered.slice(start, start + PAGE_SIZE);
|
||||
var totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
|
||||
if (slice.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#8b949e;">No PRs match filters</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = [];
|
||||
slice.forEach(function(p) {
|
||||
var outClass = p.status === 'merged' ? 'outcome-merged' :
|
||||
p.status === 'closed' ? 'outcome-closed' : 'outcome-open';
|
||||
var tierClass = (p.tier || '').toLowerCase() === 'deep' ? 'tier-deep' :
|
||||
(p.tier || '').toLowerCase() === 'standard' ? 'tier-standard' : 'tier-light';
|
||||
var date = p.created_at ? p.created_at.substring(0, 10) : '--';
|
||||
|
||||
// Summary
|
||||
var summary = p.summary || '--';
|
||||
var reviewSnippet = '';
|
||||
if (p.status === 'closed' && p.review_snippet) {
|
||||
reviewSnippet = '<div class="review-snippet">' + esc(truncate(p.review_snippet, 120)) + '</div>';
|
||||
}
|
||||
|
||||
// Outcome with tier badge
|
||||
var outcomeLabel = esc(p.status || '--');
|
||||
var tierBadge = p.tier ? ' <span class="' + tierClass + '" style="font-size:10px;">' + esc(p.tier) + '</span>' : '';
|
||||
|
||||
// Evaluator column: domain agent + model
|
||||
var evaluator = '';
|
||||
if (p.domain_agent) {
|
||||
evaluator = '<div style="font-size:12px;color:#c9d1d9;">' + esc(p.domain_agent) + '</div>';
|
||||
}
|
||||
if (p.domain_model) {
|
||||
evaluator += '<div class="model-tag">' + esc(shortModel(p.domain_model)) + '</div>';
|
||||
}
|
||||
if (p.leo_model) {
|
||||
evaluator += '<div class="model-tag">' + esc(shortModel(p.leo_model)) + '</div>';
|
||||
}
|
||||
if (!evaluator) evaluator = '<span style="color:#484f58;">--</span>';
|
||||
|
||||
// Cost — actual from DB or estimated (flagged)
|
||||
var costStr;
|
||||
if (p.cost != null && p.cost > 0) {
|
||||
if (p.cost_is_actual) {
|
||||
costStr = '<span class="cost-val">$' + p.cost.toFixed(3) + '</span>';
|
||||
} else {
|
||||
costStr = '<span class="cost-val" style="opacity:0.5;" title="Estimated — no actual cost tracked">~$' + p.cost.toFixed(3) + '</span>';
|
||||
}
|
||||
} else {
|
||||
costStr = '<span style="color:#484f58;">--</span>';
|
||||
}
|
||||
|
||||
rows.push(
|
||||
'<tr data-pr="' + p.number + '">' +
|
||||
'<td><span class="expand-chevron">▶</span> ' +
|
||||
'<a class="pr-link" href="' + FORGEJO + p.number + '" target="_blank" rel="noopener" onclick="event.stopPropagation();">#' + p.number + '</a></td>' +
|
||||
'<td style="white-space:normal;"><span class="summary-text">' + esc(summary) + '</span>' + reviewSnippet + '</td>' +
|
||||
'<td style="text-align:center;">' + (p.claims_count || '--') + '</td>' +
|
||||
'<td>' + esc(p.domain || '--') + '</td>' +
|
||||
'<td class="' + outClass + '">' + outcomeLabel + tierBadge + '</td>' +
|
||||
'<td style="text-align:center;">' + (p.eval_rounds || '--') + '</td>' +
|
||||
'<td>' + evaluator + '</td>' +
|
||||
'<td>' + costStr + '</td>' +
|
||||
'<td>' + date + '</td>' +
|
||||
'</tr>' +
|
||||
'<tr id="trace-' + p.number + '" style="display:none;"><td colspan="9" style="padding:0;">' +
|
||||
'<div class="trace-panel" id="panel-' + p.number + '">Loading trace...</div>' +
|
||||
'</td></tr>'
|
||||
);
|
||||
});
|
||||
tbody.innerHTML = rows.join('');
|
||||
|
||||
// Pagination
|
||||
document.getElementById('pg-info').textContent =
|
||||
'Page ' + (totalPages > 0 ? page + 1 : 0) + ' of ' + totalPages +
|
||||
' (' + filtered.length + ' PRs)';
|
||||
document.getElementById('pg-prev').disabled = page <= 0;
|
||||
document.getElementById('pg-next').disabled = page >= totalPages - 1;
|
||||
|
||||
// Update sort arrows
|
||||
document.querySelectorAll('.pr-table th').forEach(function(th) {
|
||||
th.classList.toggle('sorted', th.dataset.col === sortCol);
|
||||
var arrow = th.querySelector('.sort-arrow');
|
||||
if (arrow) arrow.innerHTML = (th.dataset.col === sortCol && sortAsc) ? '▲' : '▼';
|
||||
});
|
||||
}
|
||||
|
||||
// Sort click
|
||||
document.querySelectorAll('.pr-table th').forEach(function(th) {
|
||||
th.addEventListener('click', function() {
|
||||
var col = th.dataset.col;
|
||||
if (col === sortCol) { sortAsc = !sortAsc; }
|
||||
else { sortCol = col; sortAsc = col === 'number' ? false : true; }
|
||||
sortData();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
// Row click -> trace expand
|
||||
document.getElementById('pr-tbody').addEventListener('click', function(e) {
|
||||
if (e.target.closest('a')) return;
|
||||
var row = e.target.closest('tr[data-pr]');
|
||||
if (!row) return;
|
||||
var pr = row.dataset.pr;
|
||||
var traceRow = document.getElementById('trace-' + pr);
|
||||
var panel = document.getElementById('panel-' + pr);
|
||||
if (!traceRow) return;
|
||||
|
||||
if (traceRow.style.display === 'none') {
|
||||
if (expandedPr && expandedPr !== pr) {
|
||||
var prev = document.getElementById('trace-' + expandedPr);
|
||||
if (prev) prev.style.display = 'none';
|
||||
var prevRow = document.querySelector('tr[data-pr="' + expandedPr + '"]');
|
||||
if (prevRow) prevRow.classList.remove('expanded');
|
||||
}
|
||||
traceRow.style.display = '';
|
||||
panel.classList.add('open');
|
||||
row.classList.add('expanded');
|
||||
expandedPr = pr;
|
||||
loadTrace(pr, panel);
|
||||
} else {
|
||||
traceRow.style.display = 'none';
|
||||
panel.classList.remove('open');
|
||||
row.classList.remove('expanded');
|
||||
expandedPr = null;
|
||||
}
|
||||
});
|
||||
|
||||
function loadTrace(pr, panel) {
|
||||
// Also find this PR in allData for claim list
|
||||
var prData = null;
|
||||
allData.forEach(function(p) { if (p.number == pr) prData = p; });
|
||||
|
||||
fetch('/api/trace/' + pr).then(function(r) { return r.json(); }).then(function(data) {
|
||||
var html = '';
|
||||
|
||||
// --- Claims contained in this PR ---
|
||||
if (prData && prData.claim_titles && prData.claim_titles.length > 0) {
|
||||
html += '<div class="section-title">Claims (' + prData.claim_titles.length + ')</div>';
|
||||
html += '<ul class="claim-list">';
|
||||
prData.claim_titles.forEach(function(t) {
|
||||
html += '<li>' + esc(t) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
// --- Issues summary ---
|
||||
var issues = [];
|
||||
if (data.timeline) {
|
||||
data.timeline.forEach(function(ev) {
|
||||
if (ev.detail && ev.detail.issues) {
|
||||
var iss = ev.detail.issues;
|
||||
if (typeof iss === 'string') { try { iss = JSON.parse(iss); } catch(e) { iss = [iss]; } }
|
||||
if (Array.isArray(iss)) {
|
||||
iss.forEach(function(i) {
|
||||
var label = String(i).replace(/_/g, ' ');
|
||||
if (issues.indexOf(label) === -1) issues.push(label);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prData && prData.review_snippet) {
|
||||
html += '<div class="issues-box">' + esc(prData.review_snippet) + '</div>';
|
||||
} else if (issues.length > 0) {
|
||||
html += '<div class="issues-box">Issues: ' + issues.map(esc).join(', ') + '</div>';
|
||||
}
|
||||
|
||||
// --- Eval chain (who reviewed with what model) ---
|
||||
var models = {};
|
||||
if (data.timeline) {
|
||||
data.timeline.forEach(function(ev) {
|
||||
if (ev.detail) {
|
||||
if (ev.detail.model) models[ev.stage + '.' + ev.event] = ev.detail.model;
|
||||
if (ev.detail.domain_model) models['domain_review'] = ev.detail.domain_model;
|
||||
if (ev.detail.leo_model) models['leo_review'] = ev.detail.leo_model;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Object.keys(models).length > 0) {
|
||||
html += '<div class="eval-chain">';
|
||||
html += '<strong style="color:#58a6ff;">Eval chain:</strong> ';
|
||||
var parts = [];
|
||||
if (models['triage.haiku_triage'] || models['triage.deterministic_triage'])
|
||||
parts.push('<span class="step"><span class="step-label">Triage</span> <span class="step-model">' + shortModel(models['triage.haiku_triage'] || 'deterministic') + '</span></span>');
|
||||
if (models['domain_review'])
|
||||
parts.push('<span class="step"><span class="step-label">Domain</span> <span class="step-model">' + shortModel(models['domain_review']) + '</span></span>');
|
||||
if (models['leo_review'])
|
||||
parts.push('<span class="step"><span class="step-label">Leo</span> <span class="step-model">' + shortModel(models['leo_review']) + '</span></span>');
|
||||
html += parts.length > 0 ? parts.join(' <span class="arrow">→</span> ') : '<span style="color:#484f58;">No model data</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// --- Timeline ---
|
||||
if (data.timeline && data.timeline.length > 0) {
|
||||
html += '<div class="section-title">Timeline</div>';
|
||||
html += '<ul class="trace-timeline">';
|
||||
data.timeline.forEach(function(ev) {
|
||||
var cls = ev.event === 'approved' ? 'ev-approved' :
|
||||
(ev.event === 'domain_rejected' || ev.event === 'tier05_rejected') ? 'ev-rejected' :
|
||||
ev.event === 'changes_requested' ? 'ev-changes' : '';
|
||||
var ts = ev.timestamp ? ev.timestamp.substring(0, 19).replace('T', ' ') : '';
|
||||
var detail = '';
|
||||
if (ev.detail) {
|
||||
if (ev.detail.tier) detail += ' tier=' + ev.detail.tier;
|
||||
if (ev.detail.reason) detail += ' — ' + esc(ev.detail.reason);
|
||||
if (ev.detail.model) detail += ' [' + esc(shortModel(ev.detail.model)) + ']';
|
||||
if (ev.detail.review_text) {
|
||||
detail += '<div class="review-text">' + esc(ev.detail.review_text).substring(0, 2000) + '</div>';
|
||||
}
|
||||
if (ev.detail.domain_review_text) {
|
||||
detail += '<div class="review-text"><strong>Domain review:</strong><br>' + esc(ev.detail.domain_review_text).substring(0, 2000) + '</div>';
|
||||
}
|
||||
if (ev.detail.leo_review_text) {
|
||||
detail += '<div class="review-text"><strong>Leo review:</strong><br>' + esc(ev.detail.leo_review_text).substring(0, 2000) + '</div>';
|
||||
}
|
||||
}
|
||||
html += '<li class="' + cls + '">' +
|
||||
'<span class="ts">' + ts + '</span> ' +
|
||||
'<span class="ev">' + esc(ev.stage + '.' + ev.event) + '</span>' +
|
||||
detail + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
} else {
|
||||
html += '<div style="color:#484f58;font-size:12px;margin-top:8px;">No timeline events</div>';
|
||||
}
|
||||
|
||||
// --- Reviews ---
|
||||
if (data.reviews && data.reviews.length > 0) {
|
||||
html += '<div class="section-title">Reviews</div>';
|
||||
data.reviews.forEach(function(r) {
|
||||
var cls = r.outcome === 'approved' ? 'badge-green' :
|
||||
r.outcome === 'rejected' ? 'badge-red' : 'badge-yellow';
|
||||
html += '<div style="margin:4px 0;">' +
|
||||
'<span class="badge ' + cls + '">' + esc(r.outcome) + '</span> ' +
|
||||
'<span style="color:#8b949e;font-size:11px;">' + esc(r.reviewer || '') + ' ' +
|
||||
(r.model ? '[' + esc(shortModel(r.model)) + ']' : '') + ' ' +
|
||||
(r.reviewed_at || '').substring(0, 19) + '</span>';
|
||||
if (r.rejection_reason) {
|
||||
html += ' <code>' + esc(r.rejection_reason) + '</code>';
|
||||
}
|
||||
if (r.notes) {
|
||||
html += '<div class="review-text">' + esc(r.notes) + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = html || '<div style="color:#484f58;font-size:12px;">No trace data</div>';
|
||||
}).catch(function() {
|
||||
panel.innerHTML = '<div style="color:#f85149;font-size:12px;">Failed to load trace</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Filter listeners
|
||||
['filter-domain', 'filter-outcome', 'filter-tier'].forEach(function(id) {
|
||||
document.getElementById(id).addEventListener('change', applyFilters);
|
||||
});
|
||||
document.getElementById('filter-days').addEventListener('change', loadData);
|
||||
|
||||
// Pagination
|
||||
document.getElementById('pg-prev').addEventListener('click', function() { page--; renderTable(); });
|
||||
document.getElementById('pg-next').addEventListener('click', function() { page++; renderTable(); });
|
||||
|
||||
// Init
|
||||
loadData();
|
||||
</script>"""
|
||||
|
||||
return render_page(
|
||||
title="PR Lifecycle",
|
||||
subtitle="Every PR through the pipeline — triage to merge",
|
||||
active_path="/prs",
|
||||
body_html=body,
|
||||
scripts=scripts,
|
||||
extra_css=EXTRA_CSS,
|
||||
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,784 +0,0 @@
|
|||
"""Read-only canonical KB claim routes for Argus.
|
||||
|
||||
These routes show the Postgres-backed claim graph that Leo uses through the
|
||||
``teleo-kb`` bridge: a protected canonical summary list plus one claim, its
|
||||
evidence rows, and its graph edges.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from shared_ui import render_page
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_DIR_CANDIDATES = [
|
||||
ROOT / "scripts",
|
||||
Path(os.environ.get("TELEO_INFRA_REPO_DIR", "/opt/teleo-eval/workspaces/deploy-infra")) / "scripts",
|
||||
]
|
||||
for scripts_dir in SCRIPT_DIR_CANDIDATES:
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
|
||||
import kb_proposal_review_packet as proposal_review # noqa: E402
|
||||
|
||||
logger = logging.getLogger("argus.kb_claims")
|
||||
|
||||
KB_CLAIM_SELF_AUTH_PATHS = frozenset({"/api/kb/claims"})
|
||||
KB_CLAIM_GLOBAL_AUTH_PREFIXES = ("/api/kb/claims/", "/kb/claims/")
|
||||
KB_CLAIM_LOADER_KEY = web.AppKey("kb_claim_loader", object)
|
||||
KB_CLAIM_LIST_LOADER_KEY = web.AppKey("kb_claim_list_loader", object)
|
||||
KB_CLAIM_LIST_API_KEY = web.AppKey("kb_claim_list_api_key", str)
|
||||
|
||||
CLAIM_LIST_SCHEMA = "livingip.canonical-claims.v1"
|
||||
CLAIM_LIST_DEFAULT_LIMIT = 25
|
||||
CLAIM_LIST_MAX_LIMIT = 100
|
||||
CLAIM_LIST_MAX_RESPONSE_BYTES = 500_000
|
||||
CLAIM_LIST_MAX_QUERY_LENGTH = 200
|
||||
CLAIM_LIST_MAX_FILTER_LENGTH = 64
|
||||
CLAIM_LIST_REQUIRED_ROLE = "kb_observatory_read"
|
||||
CLAIM_LIST_DEFAULT_API_KEY_FILE = "/opt/teleo-eval/secrets/kb-observatory-api-key"
|
||||
CLAIM_LIST_DEFAULT_SECRETS_FILE = "/opt/teleo-eval/secrets/kb-observatory-read-password"
|
||||
CLAIM_LIST_HANDLER_TIMEOUT_SECONDS = 10.0
|
||||
CLAIM_LIST_PROCESS_TIMEOUT_SECONDS = 8.0
|
||||
CLAIM_LIST_CONNECT_TIMEOUT_SECONDS = 3
|
||||
CLAIM_LIST_STATEMENT_TIMEOUT_MS = 5_000
|
||||
|
||||
UUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
SIMPLE_FILTER_RE = re.compile(r"^[A-Za-z0-9_.:-]+$")
|
||||
|
||||
|
||||
class _ClaimListLoaderExited(RuntimeError):
|
||||
"""Keep loader exits inside the worker thread's normal exception channel."""
|
||||
|
||||
|
||||
def load_claim_list_api_key() -> str | None:
|
||||
"""Load the Observatory-only API token from its root-managed file."""
|
||||
path = Path(
|
||||
os.environ.get("KB_CLAIM_BROWSER_API_KEY_FILE", CLAIM_LIST_DEFAULT_API_KEY_FILE)
|
||||
)
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_mode & 0o007:
|
||||
return None
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
if (
|
||||
len(token) < 24
|
||||
or len(token) > 512
|
||||
or not token.isascii()
|
||||
or any(character.isspace() for character in token)
|
||||
):
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def _load_claim_list_password(secrets_file: str) -> str:
|
||||
"""Parse one literal ``PGPASSWORD=...`` assignment from a dedicated file."""
|
||||
path = Path(secrets_file)
|
||||
try:
|
||||
if not path.is_file():
|
||||
raise RuntimeError("canonical claim browser secrets file is unavailable")
|
||||
if path.stat().st_mode & 0o007:
|
||||
raise RuntimeError("canonical claim browser secrets file is world-accessible")
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise RuntimeError("canonical claim browser secrets file is unavailable") from exc
|
||||
|
||||
password: str | None = None
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, separator, value = line.partition("=")
|
||||
if not separator or key.strip() != "PGPASSWORD" or password is not None:
|
||||
raise RuntimeError("canonical claim browser secrets file has invalid format")
|
||||
password = value.strip()
|
||||
if len(password) >= 2 and password[0] == password[-1] and password[0] in {"'", '"'}:
|
||||
password = password[1:-1]
|
||||
|
||||
if not password or "\n" in password or "\r" in password:
|
||||
raise RuntimeError("canonical claim browser secrets file has invalid format")
|
||||
return password
|
||||
|
||||
|
||||
def is_claim_id(value: Any) -> bool:
|
||||
return bool(UUID_RE.match(str(value or "")))
|
||||
|
||||
|
||||
def claim_path(claim_id: str) -> str:
|
||||
return f"/kb/claims/{claim_id}"
|
||||
|
||||
|
||||
def claim_link_html(claim_id: Any, *, label: str | None = None) -> str:
|
||||
value = str(claim_id or "")
|
||||
if not is_claim_id(value):
|
||||
return f"<code>{escape(value or 'unknown')}</code>"
|
||||
text = label or value
|
||||
return f'<a class="claim-link" href="{claim_path(value)}"><code>{escape(text)}</code></a>'
|
||||
|
||||
|
||||
def short_claim_link_html(claim_id: Any) -> str:
|
||||
value = str(claim_id or "")
|
||||
if not is_claim_id(value):
|
||||
return f"<code>{escape(value or 'unknown')}</code>"
|
||||
return claim_link_html(value, label=value[:8])
|
||||
|
||||
|
||||
def encode_claim_cursor(sort_timestamp: str, claim_id: str) -> str:
|
||||
payload = json.dumps(
|
||||
{"updated_at": sort_timestamp, "id": claim_id},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_claim_cursor(value: str) -> tuple[str, str]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
||||
timestamp = str(payload["updated_at"])
|
||||
claim_id = str(payload["id"])
|
||||
parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
if parsed_timestamp.tzinfo is None:
|
||||
raise ValueError("cursor timestamp must be timezone-aware")
|
||||
if not is_claim_id(claim_id):
|
||||
raise ValueError("invalid claim id")
|
||||
return timestamp, claim_id.lower()
|
||||
except (binascii.Error, KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("invalid cursor") from exc
|
||||
|
||||
|
||||
def _parse_claim_list_request(request: web.Request) -> dict[str, Any]:
|
||||
raw_limit = request.query.get("limit", str(CLAIM_LIST_DEFAULT_LIMIT))
|
||||
try:
|
||||
limit = max(1, min(int(raw_limit), CLAIM_LIST_MAX_LIMIT))
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid_limit") from exc
|
||||
|
||||
filters: dict[str, Any] = {
|
||||
"q": request.query.get("q", "").strip(),
|
||||
"status": request.query.get("status", "open").strip(),
|
||||
"type": request.query.get("type", "").strip(),
|
||||
"tag": request.query.get("tag", "").strip(),
|
||||
"limit": limit,
|
||||
"cursor": request.query.get("cursor", "").strip(),
|
||||
}
|
||||
if len(filters["q"]) > CLAIM_LIST_MAX_QUERY_LENGTH:
|
||||
raise ValueError("query_too_long")
|
||||
if len(filters["cursor"]) > 512:
|
||||
raise ValueError("invalid_cursor")
|
||||
for key in ("status", "type", "tag"):
|
||||
value = filters[key]
|
||||
if len(value) > CLAIM_LIST_MAX_FILTER_LENGTH or (value and not SIMPLE_FILTER_RE.match(value)):
|
||||
raise ValueError(f"invalid_{key}")
|
||||
if filters["cursor"]:
|
||||
filters["cursor_values"] = decode_claim_cursor(filters["cursor"])
|
||||
else:
|
||||
filters["cursor_values"] = None
|
||||
return filters
|
||||
|
||||
|
||||
def _db_args() -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
secrets_file=os.environ.get(
|
||||
"KB_CLAIM_REVIEW_SECRETS_FILE",
|
||||
os.environ.get(
|
||||
"KB_PROPOSAL_REVIEW_SECRETS_FILE",
|
||||
os.environ.get("KB_APPLY_SECRETS_FILE", proposal_review.ap.DEFAULT_SECRETS_FILE),
|
||||
),
|
||||
),
|
||||
container=os.environ.get("KB_CLAIM_REVIEW_CONTAINER", proposal_review.ap.DEFAULT_CONTAINER),
|
||||
db=os.environ.get("KB_CLAIM_REVIEW_DB", proposal_review.ap.DEFAULT_DB),
|
||||
host=os.environ.get("KB_CLAIM_REVIEW_HOST", proposal_review.ap.DEFAULT_HOST),
|
||||
role=os.environ.get("KB_CLAIM_REVIEW_ROLE", proposal_review.ap.DEFAULT_ROLE),
|
||||
)
|
||||
|
||||
|
||||
def _claim_list_db_args() -> argparse.Namespace:
|
||||
role = os.environ.get("KB_CLAIM_BROWSER_ROLE", "").strip()
|
||||
secrets_file = os.environ.get("KB_CLAIM_BROWSER_SECRETS_FILE", "").strip()
|
||||
if not role and not secrets_file and Path(CLAIM_LIST_DEFAULT_SECRETS_FILE).is_file():
|
||||
role = CLAIM_LIST_REQUIRED_ROLE
|
||||
secrets_file = CLAIM_LIST_DEFAULT_SECRETS_FILE
|
||||
if not role or not secrets_file:
|
||||
raise RuntimeError("canonical claim browser read role is not configured")
|
||||
if role != CLAIM_LIST_REQUIRED_ROLE:
|
||||
raise RuntimeError(f"canonical claim browser requires role {CLAIM_LIST_REQUIRED_ROLE}")
|
||||
return argparse.Namespace(
|
||||
secrets_file=secrets_file,
|
||||
container=os.environ.get("KB_CLAIM_BROWSER_CONTAINER", proposal_review.ap.DEFAULT_CONTAINER),
|
||||
db=os.environ.get("KB_CLAIM_BROWSER_DB", proposal_review.ap.DEFAULT_DB),
|
||||
host=os.environ.get("KB_CLAIM_BROWSER_HOST", proposal_review.ap.DEFAULT_HOST),
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
def _run_claim_list_psql(args: argparse.Namespace, sql: str, password: str) -> str:
|
||||
"""Run the bounded, read-only browser query without leaking process output."""
|
||||
docker_binary = shutil.which("docker") or "docker"
|
||||
command = [
|
||||
docker_binary,
|
||||
"exec",
|
||||
"-e",
|
||||
"PGPASSWORD",
|
||||
"-e",
|
||||
f"PGCONNECT_TIMEOUT={CLAIM_LIST_CONNECT_TIMEOUT_SECONDS}",
|
||||
"-i",
|
||||
args.container,
|
||||
"psql",
|
||||
"-U",
|
||||
args.role,
|
||||
"-h",
|
||||
args.host,
|
||||
"-d",
|
||||
args.db,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-At",
|
||||
"-q",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
input=sql,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={"PGPASSWORD": password, "PATH": "/usr/bin:/bin:/usr/local/bin"},
|
||||
check=False,
|
||||
timeout=CLAIM_LIST_PROCESS_TIMEOUT_SECONDS,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise TimeoutError("canonical claim browser database query timed out") from exc
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("canonical claim browser database query failed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _load_claim_from_db(claim_id: str) -> dict[str, Any] | None:
|
||||
password = proposal_review.ap.load_password(_db_args().secrets_file)
|
||||
args = _db_args()
|
||||
sql = f"""
|
||||
with target as (
|
||||
select c.id,
|
||||
c.type,
|
||||
c.text,
|
||||
c.status,
|
||||
c.confidence,
|
||||
c.tags,
|
||||
c.superseded_by,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
from public.claims c
|
||||
where c.id = {proposal_review.ap.sql_literal(claim_id)}::uuid
|
||||
)
|
||||
select jsonb_build_object(
|
||||
'claim', jsonb_build_object(
|
||||
'id', target.id::text,
|
||||
'type', target.type,
|
||||
'text', target.text,
|
||||
'status', target.status,
|
||||
'confidence', target.confidence,
|
||||
'tags', coalesce(to_jsonb(target.tags), '[]'::jsonb),
|
||||
'superseded_by', target.superseded_by::text,
|
||||
'created_at', target.created_at::text,
|
||||
'updated_at', target.updated_at::text
|
||||
),
|
||||
'evidence', coalesce((
|
||||
select jsonb_agg(row_data order by rn)
|
||||
from (
|
||||
select row_number() over (
|
||||
order by ce.role::text,
|
||||
ce.weight desc nulls last,
|
||||
s.storage_path nulls last,
|
||||
s.url nulls last
|
||||
) as rn,
|
||||
jsonb_build_object(
|
||||
'role', ce.role::text,
|
||||
'weight', ce.weight,
|
||||
'source_type', s.source_type,
|
||||
'url', s.url,
|
||||
'storage_path', s.storage_path,
|
||||
'excerpt', left(coalesce(s.excerpt, ''), 1200)
|
||||
) as row_data
|
||||
from public.claim_evidence ce
|
||||
join public.sources s on s.id = ce.source_id
|
||||
where ce.claim_id = target.id
|
||||
limit 30
|
||||
) evidence_rows
|
||||
), '[]'::jsonb),
|
||||
'edges', coalesce((
|
||||
select jsonb_agg(row_data order by rn)
|
||||
from (
|
||||
select row_number() over (
|
||||
order by e.edge_type::text,
|
||||
other.text
|
||||
) as rn,
|
||||
jsonb_build_object(
|
||||
'direction', case when e.from_claim = target.id then 'outgoing' else 'incoming' end,
|
||||
'edge_type', e.edge_type::text,
|
||||
'connected_id', other.id::text,
|
||||
'connected_text', other.text,
|
||||
'connected_status', other.status,
|
||||
'connected_confidence', other.confidence
|
||||
) as row_data
|
||||
from public.claim_edges e
|
||||
join public.claims other
|
||||
on other.id = case when e.from_claim = target.id then e.to_claim else e.from_claim end
|
||||
where e.from_claim = target.id or e.to_claim = target.id
|
||||
limit 40
|
||||
) edge_rows
|
||||
), '[]'::jsonb)
|
||||
)::text
|
||||
from target;
|
||||
"""
|
||||
out = proposal_review.ap.run_psql(args, sql, password).strip()
|
||||
return json.loads(out) if out else None
|
||||
|
||||
|
||||
def _load_claim(request: web.Request, claim_id: str) -> dict[str, Any] | None:
|
||||
loader = request.app.get(KB_CLAIM_LOADER_KEY)
|
||||
return loader(claim_id) if loader else _load_claim_from_db(claim_id)
|
||||
|
||||
|
||||
def _load_claim_list_from_db(filters: dict[str, Any]) -> dict[str, Any]:
|
||||
args = _claim_list_db_args()
|
||||
password = _load_claim_list_password(args.secrets_file)
|
||||
where_clauses: list[str] = []
|
||||
|
||||
if filters["q"]:
|
||||
query_literal = proposal_review.ap.sql_literal(filters["q"].lower())
|
||||
where_clauses.append(
|
||||
"(strpos(lower(c.text), "
|
||||
f"{query_literal}) > 0 or exists ("
|
||||
"select 1 from unnest(coalesce(c.tags, '{}'::text[])) claim_tag "
|
||||
f"where strpos(lower(claim_tag), {query_literal}) > 0))"
|
||||
)
|
||||
if filters["status"] and filters["status"].lower() != "all":
|
||||
where_clauses.append(f"c.status::text = {proposal_review.ap.sql_literal(filters['status'])}")
|
||||
if filters["type"]:
|
||||
where_clauses.append(f"c.type::text = {proposal_review.ap.sql_literal(filters['type'])}")
|
||||
if filters["tag"]:
|
||||
where_clauses.append(
|
||||
f"{proposal_review.ap.sql_literal(filters['tag'])} = any(coalesce(c.tags, '{{}}'::text[]))"
|
||||
)
|
||||
|
||||
where_sql = f"where {' and '.join(where_clauses)}" if where_clauses else ""
|
||||
cursor_sql = ""
|
||||
if filters["cursor_values"]:
|
||||
cursor_timestamp, cursor_id = filters["cursor_values"]
|
||||
cursor_sql = (
|
||||
"where (sort_timestamp, id) < ("
|
||||
f"{proposal_review.ap.sql_literal(cursor_timestamp)}::timestamptz, "
|
||||
f"{proposal_review.ap.sql_literal(cursor_id)}::uuid)"
|
||||
)
|
||||
|
||||
fetch_limit = filters["limit"] + 1
|
||||
sql = f"""
|
||||
set statement_timeout = '{CLAIM_LIST_STATEMENT_TIMEOUT_MS}ms';
|
||||
set lock_timeout = '1000ms';
|
||||
|
||||
with filtered as (
|
||||
select c.id,
|
||||
c.type,
|
||||
c.text,
|
||||
c.status,
|
||||
c.confidence,
|
||||
c.tags,
|
||||
c.superseded_by,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
coalesce(c.updated_at, c.created_at, '1970-01-01'::timestamptz) as sort_timestamp
|
||||
from public.claims c
|
||||
{where_sql}
|
||||
), page_rows as (
|
||||
select *
|
||||
from filtered
|
||||
{cursor_sql}
|
||||
order by sort_timestamp desc, id desc
|
||||
limit {fetch_limit}
|
||||
), enriched_page_rows as (
|
||||
select page_rows.*,
|
||||
coalesce(evidence_counts.evidence_count, 0) as evidence_count,
|
||||
coalesce(edge_counts.edge_count, 0) as edge_count
|
||||
from page_rows
|
||||
left join lateral (
|
||||
select count(*) as evidence_count
|
||||
from public.claim_evidence ce
|
||||
where ce.claim_id = page_rows.id
|
||||
) evidence_counts on true
|
||||
left join lateral (
|
||||
select count(*) as edge_count
|
||||
from public.claim_edges e
|
||||
where e.from_claim = page_rows.id or e.to_claim = page_rows.id
|
||||
) edge_counts on true
|
||||
)
|
||||
select jsonb_build_object(
|
||||
'session_read_only', current_setting('transaction_read_only'),
|
||||
'total', (select count(*) from filtered),
|
||||
'rows', coalesce((
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id::text,
|
||||
'type', type::text,
|
||||
'text', left(text, 1200),
|
||||
'text_truncated', length(text) > 1200,
|
||||
'status', status::text,
|
||||
'confidence', confidence,
|
||||
'tags', coalesce(to_jsonb(tags), '[]'::jsonb),
|
||||
'superseded_by', superseded_by::text,
|
||||
'created_at', created_at::text,
|
||||
'updated_at', updated_at::text,
|
||||
'evidence_count', evidence_count,
|
||||
'edge_count', edge_count,
|
||||
'_cursor_timestamp', sort_timestamp::text
|
||||
) order by sort_timestamp desc, id desc
|
||||
) from enriched_page_rows
|
||||
), '[]'::jsonb)
|
||||
)::text;
|
||||
"""
|
||||
out = _run_claim_list_psql(args, sql, password).strip()
|
||||
result = json.loads(out) if out else {"total": 0, "rows": []}
|
||||
if result.pop("session_read_only", None) != "on":
|
||||
raise RuntimeError("canonical claim browser database session is not read-only")
|
||||
return result
|
||||
|
||||
|
||||
def _load_claim_list_sync(request: web.Request, filters: dict[str, Any]) -> dict[str, Any]:
|
||||
loader = request.app.get(KB_CLAIM_LIST_LOADER_KEY)
|
||||
try:
|
||||
return loader(filters) if loader else _load_claim_list_from_db(filters)
|
||||
except SystemExit as exc:
|
||||
# Python 3.11 can propagate SystemExit from asyncio.to_thread before the
|
||||
# awaiting handler gets a chance to sanitize it. Convert only that exit
|
||||
# signal here; KeyboardInterrupt and other BaseException types retain
|
||||
# their normal process-level semantics.
|
||||
raise _ClaimListLoaderExited from exc
|
||||
|
||||
|
||||
async def _load_claim_list(request: web.Request, filters: dict[str, Any]) -> dict[str, Any]:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(_load_claim_list_sync, request, filters),
|
||||
timeout=CLAIM_LIST_HANDLER_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_claim_summary(row: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
claim_id = str(row.get("id") or "").lower()
|
||||
if not is_claim_id(claim_id):
|
||||
raise ValueError("invalid canonical claim row")
|
||||
cursor_timestamp = str(row.get("_cursor_timestamp") or row.get("updated_at") or row.get("created_at") or "")
|
||||
parsed_timestamp = datetime.fromisoformat(cursor_timestamp.replace("Z", "+00:00"))
|
||||
if parsed_timestamp.tzinfo is None:
|
||||
raise ValueError("invalid canonical claim timestamp")
|
||||
tags = [str(tag)[:128] for tag in (row.get("tags") or []) if isinstance(tag, (str, int, float))][:32]
|
||||
summary = {
|
||||
"id": claim_id,
|
||||
"type": str(row.get("type") or "unknown")[:64],
|
||||
"text": str(row.get("text") or "")[:1200],
|
||||
"text_truncated": bool(row.get("text_truncated")),
|
||||
"status": str(row.get("status") or "unknown")[:64],
|
||||
"confidence": row.get("confidence"),
|
||||
"tags": tags,
|
||||
"superseded_by": str(row["superseded_by"]) if is_claim_id(row.get("superseded_by")) else None,
|
||||
"created_at": str(row.get("created_at") or "") or None,
|
||||
"updated_at": str(row.get("updated_at") or "") or None,
|
||||
"evidence_count": max(0, int(row.get("evidence_count") or 0)),
|
||||
"edge_count": max(0, int(row.get("edge_count") or 0)),
|
||||
}
|
||||
return summary, cursor_timestamp
|
||||
|
||||
|
||||
def _claim_list_payload(raw: dict[str, Any], filters: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_rows = list(raw.get("rows") or [])
|
||||
if len(raw_rows) > filters["limit"] + 1:
|
||||
raise ValueError("canonical claim page exceeded its requested bound")
|
||||
|
||||
sanitized_rows: list[tuple[dict[str, Any], str]] = []
|
||||
seen_ids: set[str] = set()
|
||||
previous_key: tuple[datetime, str] | None = None
|
||||
request_cursor_key: tuple[datetime, str] | None = None
|
||||
if filters["cursor_values"]:
|
||||
request_cursor_timestamp, request_cursor_id = filters["cursor_values"]
|
||||
request_cursor_key = (
|
||||
datetime.fromisoformat(request_cursor_timestamp.replace("Z", "+00:00")),
|
||||
request_cursor_id,
|
||||
)
|
||||
|
||||
for row in raw_rows:
|
||||
claim, cursor_timestamp = _sanitize_claim_summary(row)
|
||||
if claim["id"] in seen_ids:
|
||||
raise ValueError("canonical claim page contains duplicate ids")
|
||||
seen_ids.add(claim["id"])
|
||||
row_key = (
|
||||
datetime.fromisoformat(cursor_timestamp.replace("Z", "+00:00")),
|
||||
claim["id"],
|
||||
)
|
||||
if request_cursor_key is not None and row_key >= request_cursor_key:
|
||||
raise ValueError("canonical claim page crossed its request cursor")
|
||||
if previous_key is not None and row_key >= previous_key:
|
||||
raise ValueError("canonical claim page is not strictly ordered")
|
||||
previous_key = row_key
|
||||
sanitized_rows.append((claim, cursor_timestamp))
|
||||
|
||||
visible_rows = sanitized_rows[: filters["limit"]]
|
||||
total = max(0, int(raw.get("total") or 0))
|
||||
if total < len(visible_rows):
|
||||
raise ValueError("canonical claim total is smaller than its page")
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def build_payload(page_rows: list[tuple[dict[str, Any], str]]) -> dict[str, Any]:
|
||||
claims = [claim for claim, _cursor_timestamp in page_rows]
|
||||
has_more = len(page_rows) < len(sanitized_rows)
|
||||
last_cursor_timestamp = page_rows[-1][1] if page_rows else ""
|
||||
next_cursor = (
|
||||
encode_claim_cursor(last_cursor_timestamp, claims[-1]["id"])
|
||||
if has_more and claims
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"schema": CLAIM_LIST_SCHEMA,
|
||||
"generated_at": generated_at,
|
||||
"read_only": True,
|
||||
"source": {
|
||||
"store": "canonical_postgres",
|
||||
"relation": "public.claims",
|
||||
"receipt": "server-side read-only query",
|
||||
},
|
||||
"filters": {
|
||||
"q": filters["q"] or None,
|
||||
"status": filters["status"] or None,
|
||||
"type": filters["type"] or None,
|
||||
"tag": filters["tag"] or None,
|
||||
},
|
||||
"page": {
|
||||
"limit": filters["limit"],
|
||||
"returned": len(claims),
|
||||
"total": total,
|
||||
"has_more": has_more,
|
||||
"next_cursor": next_cursor,
|
||||
},
|
||||
"claims": claims,
|
||||
}
|
||||
|
||||
payload = build_payload(visible_rows)
|
||||
if len(json.dumps(payload).encode("utf-8")) <= CLAIM_LIST_MAX_RESPONSE_BYTES:
|
||||
return payload
|
||||
|
||||
fitted_payload: dict[str, Any] | None = None
|
||||
low = 1
|
||||
high = len(visible_rows) - 1
|
||||
while low <= high:
|
||||
midpoint = (low + high) // 2
|
||||
candidate = build_payload(visible_rows[:midpoint])
|
||||
if len(json.dumps(candidate).encode("utf-8")) <= CLAIM_LIST_MAX_RESPONSE_BYTES:
|
||||
fitted_payload = candidate
|
||||
low = midpoint + 1
|
||||
else:
|
||||
high = midpoint - 1
|
||||
if fitted_payload is None:
|
||||
raise ValueError("canonical claim response exceeded its byte bound")
|
||||
return fitted_payload
|
||||
|
||||
|
||||
def _private_json_response(payload: dict[str, Any], *, status: int = 200) -> web.Response:
|
||||
response = web.json_response(payload, status=status)
|
||||
response.headers["Cache-Control"] = "private, no-store, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Vary"] = "X-Api-Key"
|
||||
return response
|
||||
|
||||
|
||||
def _private_html_response(body: str, *, status: int = 200) -> web.Response:
|
||||
response = web.Response(text=body, content_type="text/html", status=status)
|
||||
response.headers["Cache-Control"] = "private, no-store, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Vary"] = "X-Api-Key"
|
||||
return response
|
||||
|
||||
|
||||
def _metadata_table(claim: dict[str, Any]) -> str:
|
||||
rows = [
|
||||
("ID", claim_link_html(claim.get("id"))),
|
||||
("Status", f"<code>{escape(str(claim.get('status') or 'unknown'))}</code>"),
|
||||
("Type", f"<code>{escape(str(claim.get('type') or 'unknown'))}</code>"),
|
||||
("Confidence", f"<code>{escape(str(claim.get('confidence') or 'unknown'))}</code>"),
|
||||
("Tags", f"<code>{escape(', '.join(claim.get('tags') or []) or 'none')}</code>"),
|
||||
("Created", f"<code>{escape(str(claim.get('created_at') or 'unknown'))}</code>"),
|
||||
("Updated", f"<code>{escape(str(claim.get('updated_at') or 'unknown'))}</code>"),
|
||||
]
|
||||
if claim.get("superseded_by"):
|
||||
rows.append(("Superseded by", claim_link_html(claim["superseded_by"])))
|
||||
return "\n".join(f"<tr><th>{escape(label)}</th><td>{value}</td></tr>" for label, value in rows)
|
||||
|
||||
|
||||
def _render_evidence(evidence: list[dict[str, Any]]) -> str:
|
||||
if not evidence:
|
||||
return '<div class="empty-state">No evidence rows found for this claim.</div>'
|
||||
rows = []
|
||||
for row in evidence:
|
||||
source = row.get("url") or row.get("storage_path") or "no source pointer"
|
||||
source_html = escape(str(source))
|
||||
if row.get("url"):
|
||||
source_html = f'<a href="{escape(str(row["url"]))}">{source_html}</a>'
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><code>{escape(str(row.get('role') or 'unknown'))}</code></td>"
|
||||
f"<td><code>{escape(str(row.get('weight') or ''))}</code></td>"
|
||||
f"<td><code>{escape(str(row.get('source_type') or 'unknown'))}</code></td>"
|
||||
f"<td>{source_html}</td>"
|
||||
f"<td>{escape(str(row.get('excerpt') or ''))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
return (
|
||||
"<table class=\"claim-table\"><thead><tr><th>Role</th><th>Weight</th>"
|
||||
"<th>Source type</th><th>Source</th><th>Excerpt</th></tr></thead>"
|
||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
def _render_edges(edges: list[dict[str, Any]]) -> str:
|
||||
if not edges:
|
||||
return '<div class="empty-state">No graph edges found for this claim.</div>'
|
||||
rows = []
|
||||
for row in edges:
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td><code>{escape(str(row.get('direction') or 'unknown'))}</code></td>"
|
||||
f"<td><code>{escape(str(row.get('edge_type') or 'unknown'))}</code></td>"
|
||||
f"<td>{short_claim_link_html(row.get('connected_id'))}</td>"
|
||||
f"<td>{escape(str(row.get('connected_text') or ''))}</td>"
|
||||
f"<td><code>{escape(str(row.get('connected_status') or ''))}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
return (
|
||||
"<table class=\"claim-table\"><thead><tr><th>Direction</th><th>Edge</th>"
|
||||
"<th>Connected claim</th><th>Text</th><th>Status</th></tr></thead>"
|
||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
def render_kb_claim_page(data: dict[str, Any]) -> str:
|
||||
claim = data["claim"]
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
body = f"""
|
||||
<div class="claim-card">
|
||||
<div class="label">Canonical claim</div>
|
||||
<p class="claim-text">{escape(str(claim.get("text") or ""))}</p>
|
||||
<table class="proposal-detail"><tbody>{_metadata_table(claim)}</tbody></table>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Evidence</div>
|
||||
{_render_evidence(data.get("evidence") or [])}
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Edges</div>
|
||||
{_render_edges(data.get("edges") or [])}
|
||||
</div>"""
|
||||
extra_css = """
|
||||
.claim-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||||
.claim-text { margin-top: 8px; color: #f0f6fc; font-size: 17px; line-height: 1.45; }
|
||||
.claim-link { color: #58a6ff; text-decoration: none; }
|
||||
.claim-link:hover { text-decoration: underline; }
|
||||
.claim-table td { vertical-align: top; line-height: 1.35; }
|
||||
.claim-table td:nth-child(4), .claim-table td:nth-child(5) { overflow-wrap: anywhere; }
|
||||
.proposal-detail { margin-top: 14px; }
|
||||
.proposal-detail th { width: 150px; vertical-align: top; }
|
||||
.empty-state { color: #8b949e; background: #161b22; border: 1px solid #30363d;
|
||||
border-radius: 8px; padding: 14px; }
|
||||
"""
|
||||
return render_page(
|
||||
"KB Claim",
|
||||
"Canonical claim, evidence rows, and graph edges",
|
||||
"/kb-proposals",
|
||||
body,
|
||||
extra_css=extra_css,
|
||||
timestamp=generated_at,
|
||||
)
|
||||
|
||||
|
||||
async def handle_api_kb_claim(request: web.Request) -> web.Response:
|
||||
claim_id = request.match_info["claim_id"]
|
||||
if not is_claim_id(claim_id):
|
||||
return _private_json_response({"error": "invalid_claim_id"}, status=400)
|
||||
try:
|
||||
data = _load_claim(request, claim_id)
|
||||
except (Exception, SystemExit) as exc:
|
||||
logger.error("KB claim API load failed (%s)", type(exc).__name__)
|
||||
return _private_json_response({"error": "kb_claim_load_failed"}, status=503)
|
||||
if not data:
|
||||
return _private_json_response({"error": "claim_not_found"}, status=404)
|
||||
return _private_json_response(data)
|
||||
|
||||
|
||||
async def handle_api_kb_claims(request: web.Request) -> web.Response:
|
||||
expected_api_key = request.app.get(KB_CLAIM_LIST_API_KEY)
|
||||
if not expected_api_key:
|
||||
return _private_json_response({"error": "canonical_claims_auth_unconfigured"}, status=503)
|
||||
provided_api_key = request.headers.get("X-Api-Key", "")
|
||||
try:
|
||||
authenticated = hmac.compare_digest(
|
||||
provided_api_key.encode("ascii"),
|
||||
str(expected_api_key).encode("ascii"),
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
authenticated = False
|
||||
if not authenticated:
|
||||
return _private_json_response({"error": "unauthorized"}, status=401)
|
||||
try:
|
||||
filters = _parse_claim_list_request(request)
|
||||
except ValueError as exc:
|
||||
return _private_json_response({"error": str(exc)}, status=400)
|
||||
try:
|
||||
raw = await _load_claim_list(request, filters)
|
||||
payload = _claim_list_payload(raw, filters)
|
||||
except (Exception, SystemExit) as exc:
|
||||
logger.error("Canonical KB claim list load failed (%s)", type(exc).__name__)
|
||||
return _private_json_response({"error": "canonical_claims_unavailable"}, status=503)
|
||||
return _private_json_response(payload)
|
||||
|
||||
|
||||
async def handle_kb_claim_page(request: web.Request) -> web.Response:
|
||||
claim_id = request.match_info["claim_id"]
|
||||
if not is_claim_id(claim_id):
|
||||
return _private_html_response("Invalid claim id", status=400)
|
||||
try:
|
||||
data = _load_claim(request, claim_id)
|
||||
except (Exception, SystemExit) as exc:
|
||||
logger.error("KB claim page load failed (%s)", type(exc).__name__)
|
||||
return _private_html_response(
|
||||
render_page(
|
||||
"KB Claim",
|
||||
"Canonical claim, evidence rows, and graph edges",
|
||||
"/kb-proposals",
|
||||
'<div class="alert-banner alert-critical">Canonical claim is temporarily unavailable.</div>',
|
||||
),
|
||||
status=500,
|
||||
)
|
||||
if not data:
|
||||
return _private_html_response("Claim not found", status=404)
|
||||
return _private_html_response(render_kb_claim_page(data))
|
||||
|
||||
|
||||
def register_kb_claim_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/kb/claims", handle_api_kb_claims)
|
||||
app.router.add_get("/api/kb/claims/{claim_id}", handle_api_kb_claim)
|
||||
app.router.add_get("/kb/claims/{claim_id}", handle_kb_claim_page)
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
"""Read-only KB proposal review routes for Argus.
|
||||
|
||||
This surface is the operator-visible bridge between Leo's Telegram KB reasoning
|
||||
and the narrow apply worker. It intentionally performs no approve/reject/apply
|
||||
mutation; it only renders the same packets produced by
|
||||
``scripts/kb_proposal_review_packet.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from aiohttp import web
|
||||
from shared_ui import render_page
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_DIR_CANDIDATES = [
|
||||
ROOT / "scripts",
|
||||
Path(os.environ.get("TELEO_INFRA_REPO_DIR", "/opt/teleo-eval/workspaces/deploy-infra")) / "scripts",
|
||||
]
|
||||
for scripts_dir in SCRIPT_DIR_CANDIDATES:
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
|
||||
import kb_proposal_normalize as proposal_normalize # noqa: E402
|
||||
import kb_proposal_review_packet as proposal_review # noqa: E402
|
||||
from kb_claim_routes import claim_link_html # noqa: E402
|
||||
|
||||
logger = logging.getLogger("argus.kb_proposals")
|
||||
|
||||
KB_PROPOSAL_PUBLIC_PATHS = frozenset({"/kb-proposals", "/api/kb-proposals"})
|
||||
KB_PROPOSAL_LOADER_KEY = web.AppKey("kb_proposal_loader", object)
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
|
||||
|
||||
def _query_limit(value: str | None) -> int:
|
||||
if not value:
|
||||
return DEFAULT_LIMIT
|
||||
try:
|
||||
limit = int(value)
|
||||
except ValueError:
|
||||
return DEFAULT_LIMIT
|
||||
return max(1, min(limit, MAX_LIMIT))
|
||||
|
||||
|
||||
def _query_filters(request: web.Request) -> dict[str, Any]:
|
||||
status = request.query.get("status", "approved").strip() or "approved"
|
||||
proposal_id = (request.query.get("proposal_id") or "").strip()
|
||||
if proposal_id:
|
||||
status = ""
|
||||
return {
|
||||
"status": status,
|
||||
"proposal_id": proposal_id,
|
||||
"limit": _query_limit(request.query.get("limit")),
|
||||
}
|
||||
|
||||
|
||||
def _db_args(filters: dict[str, Any]) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
proposal_id=filters["proposal_id"] or None,
|
||||
status=filters["status"] or None,
|
||||
limit=filters["limit"],
|
||||
secrets_file=os.environ.get(
|
||||
"KB_PROPOSAL_REVIEW_SECRETS_FILE",
|
||||
os.environ.get("KB_APPLY_SECRETS_FILE", proposal_review.ap.DEFAULT_SECRETS_FILE),
|
||||
),
|
||||
container=os.environ.get("KB_PROPOSAL_REVIEW_CONTAINER", proposal_review.ap.DEFAULT_CONTAINER),
|
||||
db=os.environ.get("KB_PROPOSAL_REVIEW_DB", proposal_review.ap.DEFAULT_DB),
|
||||
host=os.environ.get("KB_PROPOSAL_REVIEW_HOST", proposal_review.ap.DEFAULT_HOST),
|
||||
role=os.environ.get("KB_PROPOSAL_REVIEW_ROLE", proposal_review.ap.DEFAULT_ROLE),
|
||||
)
|
||||
|
||||
|
||||
def _load_packets(request: web.Request, filters: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
loader = request.app.get(KB_PROPOSAL_LOADER_KEY)
|
||||
proposals = loader(_db_args(filters)) if loader else proposal_review.load_from_db(_db_args(filters))
|
||||
packets = []
|
||||
for proposal in proposals:
|
||||
packet = proposal_review.classify_proposal(proposal)
|
||||
packet["normalization_preview"] = proposal_normalize.normalize_proposal(proposal)
|
||||
packet["apply_preview"] = build_apply_preview(proposal, packet)
|
||||
packets.append(packet)
|
||||
return packets
|
||||
|
||||
|
||||
def _counts(packets: list[dict[str, Any]], key: str) -> dict[str, int]:
|
||||
return dict(Counter(str(packet.get(key) or "unknown") for packet in packets))
|
||||
|
||||
|
||||
def build_kb_proposal_response(packets: list[dict[str, Any]], filters: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"filters": filters,
|
||||
"total": len(packets),
|
||||
"status_counts": _counts(packets, "status"),
|
||||
"review_state_counts": _counts(packets, "review_state"),
|
||||
"worker_applyable_count": sum(1 for packet in packets if packet.get("worker_applyable")),
|
||||
"packets": packets,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
||||
def _badge_class(value: str) -> str:
|
||||
if value in {"applied", "approved_applyable"}:
|
||||
return "badge-green"
|
||||
if value in {"approved", "approved_needs_apply_payload", "needs_human_review", "pending_review"}:
|
||||
return "badge-yellow"
|
||||
if value in {"unsupported_by_apply_worker", "not_ready"}:
|
||||
return "badge-red"
|
||||
return "badge-blue"
|
||||
|
||||
|
||||
def _badge(value: Any) -> str:
|
||||
text = escape(str(value or "unknown"))
|
||||
return f'<span class="badge {_badge_class(str(value or ""))}">{text}</span>'
|
||||
|
||||
|
||||
def _join_values(values: list[Any]) -> str:
|
||||
if not values:
|
||||
return '<span class="muted">none</span>'
|
||||
return ", ".join(escape(str(value)) for value in values)
|
||||
|
||||
|
||||
def _code(value: Any) -> str:
|
||||
return f"<code>{escape(str(value))}</code>"
|
||||
|
||||
|
||||
def _payload_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _payload_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _apply_preview_row(
|
||||
*,
|
||||
action: str,
|
||||
table: str,
|
||||
target: str,
|
||||
claim_ids: list[str] | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
source: str = "proposal",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"action": action,
|
||||
"table": table,
|
||||
"target": target,
|
||||
"claim_ids": claim_ids or [],
|
||||
"details": details or {},
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def _strict_apply_preview_rows(
|
||||
proposal_type: str | None,
|
||||
apply_payload: dict[str, Any],
|
||||
*,
|
||||
source: str = "proposal.apply_payload",
|
||||
) -> list[dict[str, Any]]:
|
||||
if proposal_type == "add_edge":
|
||||
from_claim = str(apply_payload.get("from_claim") or "")
|
||||
to_claim = str(apply_payload.get("to_claim") or "")
|
||||
edge_type = str(apply_payload.get("edge_type") or "")
|
||||
return [
|
||||
_apply_preview_row(
|
||||
action="insert_if_missing",
|
||||
table="public.claim_edges",
|
||||
target=f"{from_claim} -> {to_claim} ({edge_type or 'edge_type missing'})",
|
||||
claim_ids=[claim_id for claim_id in [from_claim, to_claim] if claim_id],
|
||||
details={
|
||||
"from_claim": from_claim,
|
||||
"to_claim": to_claim,
|
||||
"edge_type": edge_type,
|
||||
"weight": apply_payload.get("weight"),
|
||||
},
|
||||
source=source,
|
||||
)
|
||||
]
|
||||
|
||||
if proposal_type == "attach_evidence":
|
||||
rows = []
|
||||
for evidence in _payload_list(apply_payload.get("evidence")):
|
||||
ev = _payload_dict(evidence)
|
||||
claim_id = str(ev.get("claim_id") or "")
|
||||
source_id = str(ev.get("source_id") or "")
|
||||
role = str(ev.get("role") or "grounds")
|
||||
rows.append(
|
||||
_apply_preview_row(
|
||||
action="insert_if_missing",
|
||||
table="public.claim_evidence",
|
||||
target=f"{claim_id} <= {source_id} ({role})",
|
||||
claim_ids=[claim_id] if claim_id else [],
|
||||
details={
|
||||
"claim_id": claim_id,
|
||||
"source_id": source_id,
|
||||
"role": role,
|
||||
"weight": ev.get("weight"),
|
||||
},
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
if proposal_type == "revise_strategy":
|
||||
agent_id = str(apply_payload.get("agent_id") or "")
|
||||
strategy_nodes = _payload_list(apply_payload.get("strategy_nodes"))
|
||||
return [
|
||||
_apply_preview_row(
|
||||
action="update_then_insert",
|
||||
table="public.strategies",
|
||||
target=f"new active strategy for agent {agent_id or 'unknown'}",
|
||||
details={
|
||||
"agent_id": agent_id,
|
||||
"strategy_keys": sorted(_payload_dict(apply_payload.get("strategy")).keys()),
|
||||
},
|
||||
source=source,
|
||||
),
|
||||
_apply_preview_row(
|
||||
action="retire_then_insert",
|
||||
table="public.strategy_nodes",
|
||||
target=f"{len(strategy_nodes)} replacement strategy node(s)",
|
||||
details={"agent_id": agent_id, "strategy_node_count": len(strategy_nodes)},
|
||||
source=source,
|
||||
),
|
||||
]
|
||||
|
||||
return [
|
||||
_apply_preview_row(
|
||||
action="blocked",
|
||||
table="none",
|
||||
target=f"unsupported proposal_type {proposal_type or 'unknown'}",
|
||||
details={"proposal_type": proposal_type},
|
||||
source=source,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def build_apply_preview(proposal: dict[str, Any], packet: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build a read-only, row-level preview of what an apply step would touch."""
|
||||
payload = _payload_dict(proposal.get("payload"))
|
||||
apply_payload = _payload_dict(payload.get("apply_payload"))
|
||||
proposal_type = proposal.get("proposal_type")
|
||||
normalization = _payload_dict(packet.get("normalization_preview"))
|
||||
|
||||
if proposal.get("status") == "applied":
|
||||
return {
|
||||
"state": "applied",
|
||||
"rows": [],
|
||||
"blocked_fragments": [],
|
||||
"note": "Proposal is already applied; use the canonical claim/edge/evidence readback.",
|
||||
}
|
||||
|
||||
if apply_payload and proposal_type in proposal_review.ap.APPLYABLE_TYPES:
|
||||
return {
|
||||
"state": "strict_apply_payload_ready",
|
||||
"rows": _strict_apply_preview_rows(proposal_type, apply_payload),
|
||||
"blocked_fragments": [],
|
||||
"note": "Preview only; the page does not execute canonical writes.",
|
||||
}
|
||||
|
||||
child_rows = []
|
||||
for index, child in enumerate(_payload_list(normalization.get("strict_child_proposals"))):
|
||||
child_payload = _payload_dict(_payload_dict(child).get("payload"))
|
||||
child_apply_payload = _payload_dict(child_payload.get("apply_payload"))
|
||||
child_type = str(_payload_dict(child).get("proposal_type") or "")
|
||||
child_rows.extend(
|
||||
_strict_apply_preview_rows(
|
||||
child_type,
|
||||
child_apply_payload,
|
||||
source=f"normalization.strict_child_proposals[{index}]",
|
||||
)
|
||||
)
|
||||
|
||||
blocked = _payload_list(normalization.get("blocked_fragments"))
|
||||
if child_rows and not blocked:
|
||||
state = "strict_child_proposals_ready"
|
||||
note = "Preview of strict child proposals; reviewer still needs to stage those children before apply."
|
||||
elif child_rows:
|
||||
state = "partial_preview_blocked_fragments"
|
||||
note = "Some strict child rows can be previewed; blocked fragments still need canonical IDs or schema decisions."
|
||||
else:
|
||||
state = "not_applyable_yet"
|
||||
note = "No canonical write preview is safe until the proposal has a strict apply_payload or strict child proposals."
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"rows": child_rows,
|
||||
"blocked_fragments": blocked,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
|
||||
def _status_link(label: str, status: str, filters: dict[str, Any]) -> str:
|
||||
query = urlencode({"status": status, "limit": filters["limit"]})
|
||||
return f'<a class="filter-link" href="/kb-proposals?{query}">{escape(label)}</a>'
|
||||
|
||||
|
||||
def _apply_preview_html(preview: dict[str, Any]) -> str:
|
||||
state = _badge(preview.get("state"))
|
||||
rows = _payload_list(preview.get("rows"))
|
||||
blocked = _payload_list(preview.get("blocked_fragments"))
|
||||
note = escape(str(preview.get("note") or ""))
|
||||
row_html = ""
|
||||
if rows:
|
||||
rendered_rows = []
|
||||
for row in rows:
|
||||
details = _payload_dict(_payload_dict(row).get("details"))
|
||||
detail_text = ", ".join(
|
||||
f"{escape(str(key))}={escape(str(value))}"
|
||||
for key, value in details.items()
|
||||
if value not in (None, "")
|
||||
)
|
||||
claim_links = " ".join(
|
||||
claim_link_html(claim_id, label=str(claim_id)[:8])
|
||||
for claim_id in _payload_list(_payload_dict(row).get("claim_ids"))
|
||||
) or '<span class="muted">none</span>'
|
||||
detail_html = escape(detail_text) if detail_text else '<span class="muted">none</span>'
|
||||
rendered_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{_code(_payload_dict(row).get('action') or 'unknown')}</td>"
|
||||
f"<td>{_code(_payload_dict(row).get('table') or 'unknown')}</td>"
|
||||
f"<td>{escape(str(_payload_dict(row).get('target') or ''))}</td>"
|
||||
f"<td>{claim_links}</td>"
|
||||
f"<td>{detail_html}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
row_html = f"""
|
||||
<table class="proposal-detail apply-preview-table">
|
||||
<thead><tr><th>Action</th><th>Table</th><th>Target</th><th>Claims</th><th>Details</th></tr></thead>
|
||||
<tbody>{''.join(rendered_rows)}</tbody>
|
||||
</table>"""
|
||||
else:
|
||||
row_html = '<p class="muted">No canonical row preview is executable yet.</p>'
|
||||
|
||||
blocked_html = ""
|
||||
if blocked:
|
||||
blocked_rows = []
|
||||
for fragment in blocked:
|
||||
frag = _payload_dict(fragment)
|
||||
missing = ", ".join(escape(str(value)) for value in _payload_list(frag.get("missing"))) or "unknown"
|
||||
blocked_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{_code(frag.get('kind') or 'fragment')}</td>"
|
||||
f"<td>{escape(str(frag.get('reason') or 'blocked'))}</td>"
|
||||
f"<td>{missing}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
blocked_html = f"""
|
||||
<div class="label preview-label">Blocked fragments</div>
|
||||
<table class="proposal-detail apply-preview-table">
|
||||
<thead><tr><th>Kind</th><th>Reason</th><th>Missing</th></tr></thead>
|
||||
<tbody>{''.join(blocked_rows)}</tbody>
|
||||
</table>"""
|
||||
|
||||
return f"""
|
||||
<div class="next-action apply-preview">
|
||||
<div class="label">Apply preview</div>
|
||||
<p>{state} <span class="muted">{note}</span></p>
|
||||
{row_html}
|
||||
{blocked_html}
|
||||
</div>"""
|
||||
|
||||
|
||||
def _packet_card(packet: dict[str, Any]) -> str:
|
||||
auth = packet.get("identity_and_authorization") or {}
|
||||
summary = packet.get("payload_summary") or {}
|
||||
applyability = packet.get("applyability") or {}
|
||||
proposal_id_raw = str(packet.get("proposal_id") or "unknown")
|
||||
proposal_id = _code(proposal_id_raw)
|
||||
proposal_kind = escape(str(summary.get("proposal_kind") or "unknown"))
|
||||
next_action = escape(str(packet.get("next_admin_action") or "No next action recorded."))
|
||||
source_ref = escape(str(auth.get("source_ref") or "unknown"))
|
||||
channel = escape(str(auth.get("channel") or "unknown"))
|
||||
reviewed_by = escape(str(auth.get("reviewed_by_handle") or "unreviewed"))
|
||||
|
||||
rows = [
|
||||
("Status", _badge(packet.get("status"))),
|
||||
("Review state", _badge(packet.get("review_state"))),
|
||||
("Worker applyable", "yes" if packet.get("worker_applyable") else "no"),
|
||||
("Has strict apply payload", "yes" if packet.get("has_apply_payload") else "no"),
|
||||
("Proposal kind", proposal_kind),
|
||||
("Reviewed by", reviewed_by),
|
||||
("Channel", channel),
|
||||
("Source ref", source_ref),
|
||||
("Old claim id", claim_link_html(summary.get("old_claim_id") or "unknown")),
|
||||
("Claim candidates", _code(summary.get("claim_candidate_count", 0))),
|
||||
("Source candidates", _code(summary.get("source_candidate_count", 0))),
|
||||
("Supersession edges", _code(summary.get("supersession_edge_count", 0))),
|
||||
("Missing contract", _join_values(applyability.get("missing_contract") or [])),
|
||||
]
|
||||
normalization = packet.get("normalization_preview") or {}
|
||||
if normalization:
|
||||
rows.extend(
|
||||
[
|
||||
("Normalization", _badge(normalization.get("normalization_state"))),
|
||||
("Strict child proposals", _code(normalization.get("strict_child_count", 0))),
|
||||
("Blocked fragments", _code(normalization.get("blocked_count", 0))),
|
||||
]
|
||||
)
|
||||
details = "\n".join(
|
||||
f"<tr><th>{escape(label)}</th><td>{value}</td></tr>" for label, value in rows
|
||||
)
|
||||
normalization_html = ""
|
||||
if normalization:
|
||||
normalization_html = f"""
|
||||
<div class="next-action">
|
||||
<div class="label">Normalization action</div>
|
||||
<p>{escape(str(normalization.get("next_normalization_action") or "No normalization action recorded."))}</p>
|
||||
</div>"""
|
||||
apply_preview_html = _apply_preview_html(packet.get("apply_preview") or {})
|
||||
return f"""
|
||||
<article class="proposal-card">
|
||||
<div class="proposal-card-header">
|
||||
<h2>{proposal_id}</h2>
|
||||
</div>
|
||||
<table class="proposal-detail">
|
||||
<tbody>{details}</tbody>
|
||||
</table>
|
||||
<div class="next-action">
|
||||
<div class="label">Next admin action</div>
|
||||
<p>{next_action}</p>
|
||||
</div>
|
||||
{normalization_html}
|
||||
{apply_preview_html}
|
||||
</article>"""
|
||||
|
||||
|
||||
def render_kb_proposals_page(data: dict[str, Any]) -> str:
|
||||
filters = data["filters"]
|
||||
packets = data["packets"]
|
||||
generated_at = escape(str(data["generated_at"]))
|
||||
cards = "\n".join(_packet_card(packet) for packet in packets)
|
||||
if not cards:
|
||||
cards = '<div class="alert-banner alert-info">No proposals matched this filter.</div>'
|
||||
|
||||
filter_html = " ".join(
|
||||
[
|
||||
_status_link("Approved", "approved", filters),
|
||||
_status_link("Pending review", "pending_review", filters),
|
||||
_status_link("Applied", "applied", filters),
|
||||
]
|
||||
)
|
||||
body = f"""
|
||||
<div class="alert-banner alert-info">
|
||||
Read-only review surface. It shows proposal intent, applyability, missing contracts,
|
||||
and the next admin action; it does not approve, reject, or apply proposals.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Matching proposals</div><div class="value">{data["total"]}</div></div>
|
||||
<div class="card"><div class="label">Worker applyable</div><div class="value">{data["worker_applyable_count"]}</div></div>
|
||||
<div class="card"><div class="label">Current status filter</div><div class="value small-value">{escape(filters.get("status") or "by id")}</div></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Filters</div>
|
||||
<div class="filters">{filter_html}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Proposal Review Packets</div>
|
||||
<div class="proposal-list">{cards}</div>
|
||||
</div>"""
|
||||
extra_css = """
|
||||
.small-value { font-size: 16px !important; overflow-wrap: anywhere; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.filter-link { color: #58a6ff; background: #161b22; border: 1px solid #30363d;
|
||||
border-radius: 6px; padding: 6px 10px; text-decoration: none; font-size: 13px; }
|
||||
.filter-link:hover { background: #21262d; }
|
||||
.claim-link { color: #58a6ff; text-decoration: none; }
|
||||
.claim-link:hover { text-decoration: underline; }
|
||||
.proposal-list { display: grid; gap: 16px; }
|
||||
.proposal-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||||
.proposal-card-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
|
||||
.proposal-card h2 { color: #c9d1d9; font-size: 15px; overflow-wrap: anywhere; }
|
||||
.proposal-detail { margin-top: 12px; }
|
||||
.proposal-detail th { width: 210px; vertical-align: top; }
|
||||
.proposal-detail td { overflow-wrap: anywhere; }
|
||||
.next-action { margin-top: 14px; padding: 12px; border: 1px solid #30363d; border-radius: 6px; background: #0d1117; }
|
||||
.next-action .label { color: #8b949e; font-size: 11px; text-transform: uppercase; margin-bottom: 6px; }
|
||||
.next-action p { color: #c9d1d9; line-height: 1.4; }
|
||||
.muted { color: #8b949e; }
|
||||
"""
|
||||
return render_page(
|
||||
"KB Proposals",
|
||||
"Review Leo KB proposal packets before any apply step",
|
||||
"/kb-proposals",
|
||||
body,
|
||||
extra_css=extra_css,
|
||||
timestamp=generated_at,
|
||||
)
|
||||
|
||||
|
||||
async def handle_api_kb_proposals(request: web.Request) -> web.Response:
|
||||
filters = _query_filters(request)
|
||||
try:
|
||||
packets = _load_packets(request, filters)
|
||||
except BaseException as exc:
|
||||
logger.exception("KB proposal packet load failed")
|
||||
return web.json_response({"error": "kb_proposal_load_failed", "detail": str(exc)}, status=500)
|
||||
return web.json_response(build_kb_proposal_response(packets, filters))
|
||||
|
||||
|
||||
async def handle_kb_proposals_page(request: web.Request) -> web.Response:
|
||||
filters = _query_filters(request)
|
||||
try:
|
||||
packets = _load_packets(request, filters)
|
||||
except BaseException as exc:
|
||||
logger.exception("KB proposal packet page failed")
|
||||
return web.Response(
|
||||
text=render_page(
|
||||
"KB Proposals",
|
||||
"Review Leo KB proposal packets before any apply step",
|
||||
"/kb-proposals",
|
||||
f'<div class="alert-banner alert-critical">Failed to load KB proposals: {escape(str(exc))}</div>',
|
||||
),
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
data = build_kb_proposal_response(packets, filters)
|
||||
return web.Response(text=render_kb_proposals_page(data), content_type="text/html")
|
||||
|
||||
|
||||
def register_kb_proposal_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/kb-proposals", handle_api_kb_proposals)
|
||||
app.router.add_get("/kb-proposals", handle_kb_proposals_page)
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
"""Leaderboard endpoint reading from event-sourced contribution_events.
|
||||
|
||||
Owner: Argus
|
||||
Source of truth: pipeline.db contribution_events (Epimetheus, schema v25)
|
||||
|
||||
Reads contribution_events GROUP BY handle, computes CI as SUM(weight),
|
||||
joins contributors for kind, returns sorted leaderboard with role breakdown.
|
||||
|
||||
Roles + weights (Phase A):
|
||||
author 0.30 | challenger 0.25 | synthesizer 0.20 | originator 0.15 | evaluator 0.05
|
||||
|
||||
Endpoints:
|
||||
GET /api/leaderboard?window=all_time|Nd|Nh&domain=&kind=person|agent|org|all&limit=100
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger("argus.leaderboard_routes")
|
||||
|
||||
ROLE_KEYS = ("author", "challenger", "synthesizer", "originator", "evaluator")
|
||||
KIND_VALUES = ("person", "agent", "org", "all")
|
||||
|
||||
# Public path set so auth middleware lets it through
|
||||
LEADERBOARD_PUBLIC_PATHS = frozenset({"/api/leaderboard"})
|
||||
|
||||
|
||||
def _conn(app):
|
||||
"""Read-only connection to pipeline.db."""
|
||||
db_path = app["db_path"]
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _parse_window(raw):
|
||||
"""Parse window param. Returns (sql_clause, params_tuple, label).
|
||||
|
||||
Accepts: 'all_time' (default), 'Nd' (last N days), 'Nh' (last N hours).
|
||||
Caps N at 365d / 8760h to prevent abuse.
|
||||
"""
|
||||
if not raw or raw == "all_time":
|
||||
return ("", (), "all_time")
|
||||
m = re.fullmatch(r"(\d+)([dh])", raw.strip().lower())
|
||||
if not m:
|
||||
return ("", (), "all_time")
|
||||
n = int(m.group(1))
|
||||
unit = m.group(2)
|
||||
# Note: WHERE clause is composed via " AND ".join(...) — do NOT prefix with "AND ".
|
||||
if unit == "d":
|
||||
n = min(n, 365)
|
||||
return ("ce.timestamp >= datetime('now', ?)", (f"-{n} days",), f"{n}d")
|
||||
n = min(n, 8760)
|
||||
return ("ce.timestamp >= datetime('now', ?)", (f"-{n} hours",), f"{n}h")
|
||||
|
||||
|
||||
async def handle_leaderboard(request):
|
||||
"""GET /api/leaderboard.
|
||||
|
||||
Query params:
|
||||
window: 'all_time' (default) | 'Nd' (e.g. '7d') | 'Nh' (e.g. '24h')
|
||||
domain: filter by domain (optional)
|
||||
kind: 'person' (default) | 'agent' | 'org' | 'all'
|
||||
limit: max entries (default 100, max 500)
|
||||
"""
|
||||
window_clause, window_params, window_label = _parse_window(request.query.get("window"))
|
||||
domain = request.query.get("domain")
|
||||
kind = request.query.get("kind", "person")
|
||||
if kind not in KIND_VALUES:
|
||||
kind = "person"
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", "100")), 500)
|
||||
except (ValueError, TypeError):
|
||||
limit = 100
|
||||
|
||||
where = ["1=1", window_clause] if window_clause else ["1=1"]
|
||||
params = list(window_params)
|
||||
if domain:
|
||||
where.append("ce.domain = ?")
|
||||
params.append(domain)
|
||||
if kind != "all":
|
||||
where.append("COALESCE(c.kind, 'person') = ?")
|
||||
params.append(kind)
|
||||
|
||||
where_sql = " AND ".join([w for w in where if w])
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
# Aggregate per handle: total CI, per-role breakdown, event count, first/last timestamp
|
||||
# LEFT JOIN contributors so handles in events but not in contributors still appear
|
||||
# (defaults to kind='person' via COALESCE).
|
||||
rows = conn.execute(f"""
|
||||
SELECT
|
||||
ce.handle,
|
||||
COALESCE(c.kind, 'person') AS kind,
|
||||
ROUND(SUM(ce.weight), 4) AS ci,
|
||||
COUNT(*) AS events_count,
|
||||
MIN(ce.timestamp) AS first_contribution,
|
||||
MAX(ce.timestamp) AS last_contribution,
|
||||
SUM(CASE WHEN ce.role='author' THEN ce.weight ELSE 0 END) AS ci_author,
|
||||
SUM(CASE WHEN ce.role='challenger' THEN ce.weight ELSE 0 END) AS ci_challenger,
|
||||
SUM(CASE WHEN ce.role='synthesizer' THEN ce.weight ELSE 0 END) AS ci_synthesizer,
|
||||
SUM(CASE WHEN ce.role='originator' THEN ce.weight ELSE 0 END) AS ci_originator,
|
||||
SUM(CASE WHEN ce.role='evaluator' THEN ce.weight ELSE 0 END) AS ci_evaluator,
|
||||
COUNT(DISTINCT ce.domain) AS domain_count,
|
||||
COUNT(DISTINCT ce.pr_number) AS pr_count
|
||||
FROM contribution_events ce
|
||||
LEFT JOIN contributors c ON c.handle = ce.handle
|
||||
WHERE {where_sql}
|
||||
GROUP BY ce.handle, COALESCE(c.kind, 'person')
|
||||
ORDER BY ci DESC, last_contribution DESC
|
||||
LIMIT ?
|
||||
""", (*params, limit + 1)).fetchall() # +1 to detect overflow
|
||||
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
|
||||
# Total count of distinct handles matching filters (without limit)
|
||||
total_row = conn.execute(f"""
|
||||
SELECT COUNT(DISTINCT ce.handle) AS total
|
||||
FROM contribution_events ce
|
||||
LEFT JOIN contributors c ON c.handle = ce.handle
|
||||
WHERE {where_sql}
|
||||
""", params).fetchone()
|
||||
total = total_row["total"] if total_row else 0
|
||||
|
||||
leaderboard = []
|
||||
for r in rows:
|
||||
leaderboard.append({
|
||||
"handle": r["handle"],
|
||||
"kind": r["kind"],
|
||||
"ci": r["ci"],
|
||||
"ci_breakdown": {
|
||||
"author": round(r["ci_author"] or 0, 4),
|
||||
"challenger": round(r["ci_challenger"] or 0, 4),
|
||||
"synthesizer": round(r["ci_synthesizer"] or 0, 4),
|
||||
"originator": round(r["ci_originator"] or 0, 4),
|
||||
"evaluator": round(r["ci_evaluator"] or 0, 4),
|
||||
},
|
||||
"events_count": r["events_count"],
|
||||
"domain_count": r["domain_count"],
|
||||
"pr_count": r["pr_count"],
|
||||
"first_contribution": r["first_contribution"],
|
||||
"last_contribution": r["last_contribution"],
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
"window": window_label,
|
||||
"domain": domain,
|
||||
"kind_filter": kind,
|
||||
"total": total,
|
||||
"shown": len(leaderboard),
|
||||
"has_more": has_more,
|
||||
"source": "contribution_events", # explicit so consumers know the data origin
|
||||
"leaderboard": leaderboard,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_leaderboard_routes(app: web.Application):
|
||||
"""Register /api/leaderboard. Requires app['db_path'] to be set."""
|
||||
app.router.add_get("/api/leaderboard", handle_leaderboard)
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
"""Dashboard API routes for research session + cost tracking.
|
||||
|
||||
Argus-side read-only endpoints. These query the data that
|
||||
research_tracking.py writes to pipeline.db.
|
||||
|
||||
Add to app.py after alerting_routes setup.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def _conn(app):
|
||||
"""Read-only connection to pipeline.db."""
|
||||
db_path = app["db_path"]
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
async def handle_api_research_sessions(request):
|
||||
"""GET /api/research-sessions?agent=&domain=&days=7
|
||||
|
||||
Returns research sessions with linked sources and cost data.
|
||||
"""
|
||||
agent = request.query.get("agent")
|
||||
domain = request.query.get("domain")
|
||||
try:
|
||||
days = int(request.query.get("days", 7))
|
||||
except (ValueError, TypeError):
|
||||
days = 7
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
where = ["rs.started_at >= datetime('now', ?)"]
|
||||
params = [f"-{days} days"]
|
||||
|
||||
if agent:
|
||||
where.append("rs.agent = ?")
|
||||
params.append(agent)
|
||||
if domain:
|
||||
where.append("rs.domain = ?")
|
||||
params.append(domain)
|
||||
|
||||
where_clause = " AND ".join(where)
|
||||
|
||||
sessions = conn.execute(f"""
|
||||
SELECT rs.*,
|
||||
GROUP_CONCAT(s.path, '||') as source_paths,
|
||||
GROUP_CONCAT(s.status, '||') as source_statuses,
|
||||
GROUP_CONCAT(s.claims_count, '||') as source_claims,
|
||||
GROUP_CONCAT(COALESCE(s.cost_usd, 0), '||') as source_costs
|
||||
FROM research_sessions rs
|
||||
LEFT JOIN sources s ON s.session_id = rs.id
|
||||
WHERE {where_clause}
|
||||
GROUP BY rs.id
|
||||
ORDER BY rs.started_at DESC
|
||||
""", params).fetchall()
|
||||
|
||||
result = []
|
||||
for s in sessions:
|
||||
sources = []
|
||||
if s["source_paths"]:
|
||||
paths = s["source_paths"].split("||")
|
||||
statuses = (s["source_statuses"] or "").split("||")
|
||||
claims = (s["source_claims"] or "").split("||")
|
||||
costs = (s["source_costs"] or "").split("||")
|
||||
for i, p in enumerate(paths):
|
||||
sources.append({
|
||||
"path": p,
|
||||
"status": statuses[i] if i < len(statuses) else None,
|
||||
"claims_count": int(claims[i]) if i < len(claims) and claims[i] else 0,
|
||||
"extraction_cost": float(costs[i]) if i < len(costs) and costs[i] else 0,
|
||||
})
|
||||
|
||||
result.append({
|
||||
"id": s["id"],
|
||||
"agent": s["agent"],
|
||||
"domain": s["domain"],
|
||||
"topic": s["topic"],
|
||||
"reasoning": s["reasoning"],
|
||||
"summary": s["summary"],
|
||||
"sources_planned": s["sources_planned"],
|
||||
"sources_produced": s["sources_produced"],
|
||||
"model": s["model"],
|
||||
"input_tokens": s["input_tokens"],
|
||||
"output_tokens": s["output_tokens"],
|
||||
"research_cost": s["cost_usd"],
|
||||
"extraction_cost": sum(src["extraction_cost"] for src in sources),
|
||||
"total_cost": s["cost_usd"] + sum(src["extraction_cost"] for src in sources),
|
||||
"total_claims": sum(src["claims_count"] for src in sources),
|
||||
"status": s["status"],
|
||||
"started_at": s["started_at"],
|
||||
"completed_at": s["completed_at"],
|
||||
"sources": sources,
|
||||
})
|
||||
|
||||
# Summary stats
|
||||
total_sessions = len(result)
|
||||
total_cost = sum(r["total_cost"] for r in result)
|
||||
total_claims = sum(r["total_claims"] for r in result)
|
||||
total_sources = sum(r["sources_produced"] for r in result)
|
||||
|
||||
return web.json_response({
|
||||
"summary": {
|
||||
"sessions": total_sessions,
|
||||
"total_cost": round(total_cost, 2),
|
||||
"total_claims": total_claims,
|
||||
"total_sources": total_sources,
|
||||
"avg_cost_per_claim": round(total_cost / total_claims, 4) if total_claims else 0,
|
||||
"avg_cost_per_session": round(total_cost / total_sessions, 4) if total_sessions else 0,
|
||||
},
|
||||
"sessions": result,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def handle_api_costs(request):
|
||||
"""GET /api/costs?days=14&by=stage|model|date
|
||||
|
||||
Comprehensive cost breakdown. Works with EXISTING data in costs table
|
||||
plus the new extraction costs once backfilled.
|
||||
"""
|
||||
try:
|
||||
days = int(request.query.get("days", 14))
|
||||
except (ValueError, TypeError):
|
||||
days = 14
|
||||
group_by = request.query.get("by", "stage")
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
valid_groups = {"stage", "model", "date"}
|
||||
if group_by not in valid_groups:
|
||||
group_by = "stage"
|
||||
|
||||
rows = conn.execute(f"""
|
||||
SELECT {group_by},
|
||||
SUM(calls) as total_calls,
|
||||
SUM(input_tokens) as total_input,
|
||||
SUM(output_tokens) as total_output,
|
||||
SUM(cost_usd) as total_cost
|
||||
FROM costs
|
||||
WHERE date >= date('now', ?)
|
||||
GROUP BY {group_by}
|
||||
ORDER BY total_cost DESC
|
||||
""", (f"-{days} days",)).fetchall()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
group_by: r[group_by],
|
||||
"calls": r["total_calls"],
|
||||
"input_tokens": r["total_input"],
|
||||
"output_tokens": r["total_output"],
|
||||
"cost_usd": round(r["total_cost"], 4),
|
||||
})
|
||||
|
||||
grand_total = sum(r["cost_usd"] for r in result)
|
||||
|
||||
# Also get per-agent cost from sources table (extraction costs)
|
||||
agent_costs = conn.execute("""
|
||||
SELECT p.agent,
|
||||
COUNT(DISTINCT s.path) as sources,
|
||||
SUM(s.cost_usd) as extraction_cost,
|
||||
SUM(s.claims_count) as claims
|
||||
FROM sources s
|
||||
LEFT JOIN prs p ON p.source_path = s.path
|
||||
WHERE s.cost_usd > 0
|
||||
GROUP BY p.agent
|
||||
ORDER BY extraction_cost DESC
|
||||
""").fetchall()
|
||||
|
||||
agent_breakdown = []
|
||||
for r in agent_costs:
|
||||
agent_breakdown.append({
|
||||
"agent": r["agent"] or "unlinked",
|
||||
"sources": r["sources"],
|
||||
"extraction_cost": round(r["extraction_cost"], 2),
|
||||
"claims": r["claims"],
|
||||
"cost_per_claim": round(r["extraction_cost"] / r["claims"], 4) if r["claims"] else 0,
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
"period_days": days,
|
||||
"grand_total": round(grand_total, 2),
|
||||
"by_" + group_by: result,
|
||||
"by_agent": agent_breakdown,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def handle_api_source_detail(request):
|
||||
"""GET /api/source/{path}
|
||||
|
||||
Full lifecycle of a single source: research session → extraction → claims → eval outcomes.
|
||||
"""
|
||||
source_path = request.match_info["path"]
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
# Try exact match first, fall back to suffix match (anchored)
|
||||
source = conn.execute(
|
||||
"SELECT * FROM sources WHERE path = ?",
|
||||
(source_path,),
|
||||
).fetchone()
|
||||
if not source:
|
||||
# Suffix match — anchor with / prefix to avoid substring hits
|
||||
source = conn.execute(
|
||||
"SELECT * FROM sources WHERE path LIKE ? ORDER BY length(path) LIMIT 1",
|
||||
(f"%/{source_path}",),
|
||||
).fetchone()
|
||||
|
||||
if not source:
|
||||
return web.json_response({"error": "Source not found"}, status=404)
|
||||
|
||||
result = dict(source)
|
||||
|
||||
# Get research session if linked
|
||||
if source["session_id"]:
|
||||
session = conn.execute(
|
||||
"SELECT * FROM research_sessions WHERE id = ?",
|
||||
(source["session_id"],),
|
||||
).fetchone()
|
||||
result["research_session"] = dict(session) if session else None
|
||||
else:
|
||||
result["research_session"] = None
|
||||
|
||||
# Get PRs from this source
|
||||
prs = conn.execute(
|
||||
"SELECT number, status, domain, agent, tier, leo_verdict, domain_verdict, "
|
||||
"cost_usd, created_at, merged_at, commit_type, transient_retries, substantive_retries, last_error "
|
||||
"FROM prs WHERE source_path = ?",
|
||||
(source["path"],),
|
||||
).fetchall()
|
||||
result["prs"] = [dict(p) for p in prs]
|
||||
|
||||
# Get eval events from audit_log for those PRs
|
||||
# NOTE: audit_log.detail is mixed — some rows are JSON (evaluate events),
|
||||
# some are plain text. Use json_valid() to filter safely.
|
||||
pr_numbers = [p["number"] for p in prs]
|
||||
if pr_numbers:
|
||||
placeholders = ",".join("?" * len(pr_numbers))
|
||||
evals = conn.execute(f"""
|
||||
SELECT * FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
AND json_valid(detail)
|
||||
AND json_extract(detail, '$.pr') IN ({placeholders})
|
||||
ORDER BY timestamp
|
||||
""", pr_numbers).fetchall()
|
||||
result["eval_history"] = [
|
||||
{"timestamp": e["timestamp"], "event": e["event"],
|
||||
"detail": json.loads(e["detail"]) if e["detail"] else None}
|
||||
for e in evals
|
||||
]
|
||||
else:
|
||||
result["eval_history"] = []
|
||||
|
||||
return web.json_response(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def setup_research_routes(app):
|
||||
"""Register research tracking routes. Call from create_app()."""
|
||||
app.router.add_get("/api/research-sessions", handle_api_research_sessions)
|
||||
app.router.add_get("/api/costs", handle_api_costs)
|
||||
app.router.add_get("/api/source/{path:.+}", handle_api_source_detail)
|
||||
|
||||
|
||||
# Public paths to add to auth middleware
|
||||
RESEARCH_PUBLIC_PATHS = frozenset({
|
||||
"/api/research-sessions",
|
||||
"/api/costs",
|
||||
})
|
||||
# /api/source/{path} needs prefix matching — add to auth middleware:
|
||||
# if path.startswith("/api/source/"): allow
|
||||
|
|
@ -1,419 +0,0 @@
|
|||
"""Research session tracking + cost attribution for the Teleo pipeline.
|
||||
|
||||
This module adds three capabilities:
|
||||
1. research_sessions table — tracks WHY agents researched, what they found interesting,
|
||||
session cost, and links to generated sources
|
||||
2. Extraction cost attribution — writes per-source cost to sources.cost_usd after extraction
|
||||
3. Source → claim linkage — ensures prs.source_path is always populated
|
||||
|
||||
Designed for Epimetheus to integrate into the pipeline. Argus built the spec;
|
||||
Ganymede reviews; Epimetheus wires it in.
|
||||
|
||||
Data flow:
|
||||
Agent research session → research_sessions row (with reasoning + summary)
|
||||
→ sources created (with session_id FK)
|
||||
→ extraction runs (cost written to sources.cost_usd + costs table)
|
||||
→ PRs created (source_path populated)
|
||||
→ claims merged (traceable back to session)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("research_tracking")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration v11: research_sessions table + sources.session_id FK
|
||||
# (v9 is current; v10 is Epimetheus's eval pipeline migration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MIGRATION_V11_SQL = """
|
||||
-- Research session tracking table
|
||||
CREATE TABLE IF NOT EXISTS research_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent TEXT NOT NULL,
|
||||
-- Which agent ran the research (leo, rio, astra, etc.)
|
||||
domain TEXT,
|
||||
-- Primary domain of the research
|
||||
topic TEXT NOT NULL,
|
||||
-- What they researched (short description)
|
||||
reasoning TEXT,
|
||||
-- WHY they chose this topic (agent's own explanation)
|
||||
summary TEXT,
|
||||
-- What they found most interesting/relevant
|
||||
sources_planned INTEGER DEFAULT 0,
|
||||
-- How many sources they intended to produce
|
||||
sources_produced INTEGER DEFAULT 0,
|
||||
-- How many actually materialized
|
||||
model TEXT,
|
||||
-- Model used for research (e.g. claude-opus-4-6)
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
-- Total research session cost (LLM calls for discovery + writing)
|
||||
status TEXT DEFAULT 'running',
|
||||
-- running, completed, failed, partial
|
||||
started_at TEXT DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
metadata TEXT DEFAULT '{}'
|
||||
-- JSON: any extra context (prompt version, search queries used, etc.)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rs_agent ON research_sessions(agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_rs_domain ON research_sessions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_rs_started ON research_sessions(started_at);
|
||||
|
||||
-- Add session_id FK to sources table
|
||||
ALTER TABLE sources ADD COLUMN session_id INTEGER REFERENCES research_sessions(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sources_session ON sources(session_id);
|
||||
|
||||
-- Record migration
|
||||
INSERT INTO schema_version (version) VALUES (11);
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost attribution: write extraction cost to sources.cost_usd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pricing per million tokens (as of March 2026)
|
||||
MODEL_PRICING = {
|
||||
"anthropic/claude-sonnet-4.5": {"input": 3.00, "output": 15.00},
|
||||
"anthropic/claude-sonnet-4-5": {"input": 3.00, "output": 15.00},
|
||||
"anthropic/claude-haiku-4.5": {"input": 0.80, "output": 4.00},
|
||||
"anthropic/claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.00},
|
||||
"minimax/minimax-m2.5": {"input": 0.14, "output": 0.56},
|
||||
}
|
||||
|
||||
|
||||
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
||||
"""Calculate USD cost from model name and token counts."""
|
||||
pricing = MODEL_PRICING.get(model)
|
||||
if not pricing:
|
||||
# Default to Sonnet 4.5 pricing as conservative estimate
|
||||
logger.warning("Unknown model %s — using Sonnet 4.5 pricing", model)
|
||||
pricing = {"input": 3.00, "output": 15.00}
|
||||
return (input_tokens * pricing["input"] + output_tokens * pricing["output"]) / 1_000_000
|
||||
|
||||
|
||||
def record_extraction_cost(
|
||||
conn: sqlite3.Connection,
|
||||
source_path: str,
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
):
|
||||
"""Write extraction cost to both sources.cost_usd and costs table.
|
||||
|
||||
Call this after each successful extraction call in openrouter-extract-v2.py.
|
||||
This is the missing link — the CSV logger records tokens but never writes
|
||||
cost back to the DB.
|
||||
"""
|
||||
cost = calculate_cost(model, input_tokens, output_tokens)
|
||||
|
||||
# Update source row
|
||||
conn.execute(
|
||||
"UPDATE sources SET cost_usd = cost_usd + ?, extraction_model = ? WHERE path = ?",
|
||||
(cost, model, source_path),
|
||||
)
|
||||
|
||||
# Also record in costs table for dashboard aggregation
|
||||
date = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
conn.execute(
|
||||
"""INSERT INTO costs (date, model, stage, calls, input_tokens, output_tokens, cost_usd)
|
||||
VALUES (?, ?, 'extraction', 1, ?, ?, ?)
|
||||
ON CONFLICT(date, model, stage)
|
||||
DO UPDATE SET calls = calls + 1,
|
||||
input_tokens = input_tokens + excluded.input_tokens,
|
||||
output_tokens = output_tokens + excluded.output_tokens,
|
||||
cost_usd = cost_usd + excluded.cost_usd""",
|
||||
(date, model, input_tokens, output_tokens, cost),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"Recorded extraction cost for %s: $%.4f (%d in, %d out, %s)",
|
||||
source_path, cost, input_tokens, output_tokens, model,
|
||||
)
|
||||
return cost
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Research session lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def start_session(
|
||||
conn: sqlite3.Connection,
|
||||
agent: str,
|
||||
topic: str,
|
||||
domain: Optional[str] = None,
|
||||
reasoning: Optional[str] = None,
|
||||
sources_planned: int = 0,
|
||||
model: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> int:
|
||||
"""Call at the START of a research session. Returns session_id.
|
||||
|
||||
The agent should call this before it begins producing sources,
|
||||
explaining what it plans to research and why.
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO research_sessions
|
||||
(agent, domain, topic, reasoning, sources_planned, model, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
agent,
|
||||
domain,
|
||||
topic,
|
||||
reasoning,
|
||||
sources_planned,
|
||||
model,
|
||||
json.dumps(metadata or {}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
session_id = cur.lastrowid
|
||||
logger.info("Started research session #%d: %s / %s", session_id, agent, topic)
|
||||
return session_id
|
||||
|
||||
|
||||
def link_source_to_session(
|
||||
conn: sqlite3.Connection,
|
||||
source_path: str,
|
||||
session_id: int,
|
||||
):
|
||||
"""Link a source file to its research session.
|
||||
|
||||
Call this when a source is written to inbox/ during a research session.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE sources SET session_id = ? WHERE path = ?",
|
||||
(session_id, source_path),
|
||||
)
|
||||
conn.execute(
|
||||
"""UPDATE research_sessions
|
||||
SET sources_produced = sources_produced + 1
|
||||
WHERE id = ?""",
|
||||
(session_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def complete_session(
|
||||
conn: sqlite3.Connection,
|
||||
session_id: int,
|
||||
summary: str,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
cost_usd: float = 0,
|
||||
status: str = "completed",
|
||||
):
|
||||
"""Call at the END of a research session.
|
||||
|
||||
The agent should summarize what it found most interesting/relevant.
|
||||
Cost should include ALL LLM calls made during the session (web search,
|
||||
analysis, source writing — everything).
|
||||
"""
|
||||
conn.execute(
|
||||
"""UPDATE research_sessions
|
||||
SET summary = ?, input_tokens = ?, output_tokens = ?,
|
||||
cost_usd = ?, status = ?, completed_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(summary, input_tokens, output_tokens, cost_usd, status, session_id),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("Completed research session #%d: %s", session_id, status)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source → PR linkage fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_source_path_on_pr(
|
||||
conn: sqlite3.Connection,
|
||||
pr_number: int,
|
||||
source_path: str,
|
||||
):
|
||||
"""Ensure prs.source_path is populated. Call during PR creation.
|
||||
|
||||
Currently 0/1451 PRs have source_path set. This is the fix.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE prs SET source_path = ? WHERE number = ? AND (source_path IS NULL OR source_path = '')",
|
||||
(source_path, pr_number),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backfill: attribute extraction costs from existing CSV log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def backfill_extraction_costs(conn: sqlite3.Connection, csv_path: str):
|
||||
"""One-time backfill: read openrouter-usage.csv and write costs to sources + costs tables.
|
||||
|
||||
Run once to fill in the ~$338 of extraction costs that were logged to CSV
|
||||
but never written to the database.
|
||||
|
||||
Safe to re-run — only updates sources where cost_usd = 0, so partial
|
||||
runs can be resumed without double-counting.
|
||||
"""
|
||||
import csv
|
||||
|
||||
count = 0
|
||||
total_cost = 0.0
|
||||
with open(csv_path) as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
source_file = row.get("source_file", "")
|
||||
model = row.get("model", "")
|
||||
try:
|
||||
in_tok = int(row.get("input_tokens", 0) or 0)
|
||||
out_tok = int(row.get("output_tokens", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
cost = calculate_cost(model, in_tok, out_tok)
|
||||
if cost <= 0:
|
||||
continue
|
||||
|
||||
# Try to match source_file to sources.path
|
||||
# CSV has filename, DB has full path — match on exact suffix
|
||||
# Use ORDER BY length(path) to prefer shortest (most specific) match
|
||||
matched = conn.execute(
|
||||
"SELECT path FROM sources WHERE path LIKE ? AND cost_usd = 0 ORDER BY length(path) LIMIT 1",
|
||||
(f"%/{source_file}" if "/" not in source_file else f"%{source_file}",),
|
||||
).fetchone()
|
||||
|
||||
if matched:
|
||||
conn.execute(
|
||||
"UPDATE sources SET cost_usd = ?, extraction_model = ? WHERE path = ?",
|
||||
(cost, model, matched[0]),
|
||||
)
|
||||
|
||||
# Always record in costs table
|
||||
date = row.get("date", "unknown")
|
||||
conn.execute(
|
||||
"""INSERT INTO costs (date, model, stage, calls, input_tokens, output_tokens, cost_usd)
|
||||
VALUES (?, ?, 'extraction', 1, ?, ?, ?)
|
||||
ON CONFLICT(date, model, stage)
|
||||
DO UPDATE SET calls = calls + 1,
|
||||
input_tokens = input_tokens + excluded.input_tokens,
|
||||
output_tokens = output_tokens + excluded.output_tokens,
|
||||
cost_usd = cost_usd + excluded.cost_usd""",
|
||||
(date, model, in_tok, out_tok, cost),
|
||||
)
|
||||
|
||||
count += 1
|
||||
total_cost += cost
|
||||
|
||||
conn.commit()
|
||||
logger.info("Backfilled %d extraction cost records, total $%.2f", count, total_cost)
|
||||
return count, total_cost
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backfill: populate prs.source_path from branch naming convention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def backfill_source_paths(conn: sqlite3.Connection):
|
||||
"""One-time backfill: derive source_path for existing PRs from branch names.
|
||||
|
||||
Branch format: extract/YYYY-MM-DD-source-name or similar patterns.
|
||||
Source path format: inbox/queue/YYYY-MM-DD-source-name.md
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT number, branch FROM prs WHERE source_path IS NULL AND branch IS NOT NULL"
|
||||
).fetchall()
|
||||
|
||||
count = 0
|
||||
for number, branch in rows:
|
||||
# Try to extract source name from branch
|
||||
# Common patterns: extract/source-name, claims/source-name
|
||||
parts = branch.split("/", 1)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
source_stem = parts[1]
|
||||
|
||||
# Try to find matching source in DB — exact suffix match, shortest path wins
|
||||
matched = conn.execute(
|
||||
"SELECT path FROM sources WHERE path LIKE ? ORDER BY length(path) LIMIT 1",
|
||||
(f"%/{source_stem}%" if source_stem else "",),
|
||||
).fetchone()
|
||||
|
||||
if matched:
|
||||
conn.execute(
|
||||
"UPDATE prs SET source_path = ? WHERE number = ?",
|
||||
(matched[0], number),
|
||||
)
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
logger.info("Backfilled source_path for %d PRs", count)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration points (for Epimetheus to wire in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INTEGRATION_GUIDE = """
|
||||
## Where to wire this in
|
||||
|
||||
### 1. openrouter-extract-v2.py — after successful extraction call
|
||||
|
||||
from research_tracking import record_extraction_cost
|
||||
|
||||
# After line 430 (content, usage = call_openrouter(...))
|
||||
# After line 672 (log_usage(...))
|
||||
record_extraction_cost(
|
||||
conn, args.source_file, args.model,
|
||||
usage.get("prompt_tokens", 0),
|
||||
usage.get("completion_tokens", 0),
|
||||
)
|
||||
|
||||
### 2. Agent research scripts — wrap research sessions
|
||||
|
||||
from research_tracking import start_session, link_source_to_session, complete_session
|
||||
|
||||
# At start of research:
|
||||
session_id = start_session(conn, agent="leo", topic="weapons stigmatization campaigns",
|
||||
domain="grand-strategy",
|
||||
reasoning="Following up on EU AI Act national security exclusion — exploring how stigmatization
|
||||
campaigns have historically driven arms control policy",
|
||||
sources_planned=6, model="claude-opus-4-6")
|
||||
|
||||
# As each source is written:
|
||||
link_source_to_session(conn, source_path, session_id)
|
||||
|
||||
# At end of research:
|
||||
complete_session(conn, session_id,
|
||||
summary="Ottawa Treaty mine ban model is the strongest parallel to AI weapons — same
|
||||
3-condition framework (humanitarian harm + low military utility + civil society
|
||||
coalition). Ukraine Shahed case is a near-miss triggering event.",
|
||||
input_tokens=total_in, output_tokens=total_out, cost_usd=total_cost)
|
||||
|
||||
### 3. PR creation in lib/merge.py or lib/validate.py — ensure source_path
|
||||
|
||||
from research_tracking import ensure_source_path_on_pr
|
||||
|
||||
# When creating a PR, pass the source:
|
||||
ensure_source_path_on_pr(conn, pr_number, source_path)
|
||||
|
||||
### 4. One-time backfills (run manually after migration)
|
||||
|
||||
from research_tracking import backfill_extraction_costs, backfill_source_paths
|
||||
|
||||
backfill_extraction_costs(conn, "/opt/teleo-eval/logs/openrouter-usage.csv")
|
||||
backfill_source_paths(conn)
|
||||
|
||||
### 5. Migration
|
||||
|
||||
Run MIGRATION_V11_SQL against pipeline.db after backing up.
|
||||
"""
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
"""Response audit API routes — agent cost tracking, reasoning traces, unified activity.
|
||||
|
||||
Endpoints:
|
||||
GET /api/response-audit — paginated response list with cost columns
|
||||
GET /api/response-audit/{id} — single response detail with full tool_calls
|
||||
GET /api/agent-costs — aggregated cost view from response_audit
|
||||
GET /api/unified-activity — merged prs + response_audit timeline
|
||||
|
||||
Data source: response_audit table in pipeline.db (written by Epimetheus's Telegram bot).
|
||||
|
||||
Owner: Argus
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger("argus.response_audit_routes")
|
||||
|
||||
|
||||
def _conn(app):
|
||||
"""Read-only connection to pipeline.db."""
|
||||
db_path = app["db_path"]
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ─── GET /api/response-audit ─────────────────────────────────────────────
|
||||
|
||||
async def handle_response_audit_list(request):
|
||||
"""Paginated response audit list with cost and model data.
|
||||
|
||||
Query params:
|
||||
agent — filter by agent name
|
||||
hours — lookback window (default 24, max 168)
|
||||
limit — max results (default 50, max 200)
|
||||
offset — pagination offset (default 0)
|
||||
model — filter by model name (substring match)
|
||||
"""
|
||||
agent = request.query.get("agent")
|
||||
model_filter = request.query.get("model")
|
||||
try:
|
||||
hours = min(int(request.query.get("hours", 24)), 168)
|
||||
except (ValueError, TypeError):
|
||||
hours = 24
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", 50)), 200)
|
||||
except (ValueError, TypeError):
|
||||
limit = 50
|
||||
try:
|
||||
offset = max(int(request.query.get("offset", 0)), 0)
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
where = ["timestamp > datetime('now', ?)"]
|
||||
params: list = [f"-{hours} hours"]
|
||||
|
||||
if agent:
|
||||
where.append("agent = ?")
|
||||
params.append(agent)
|
||||
if model_filter:
|
||||
where.append("model LIKE ?")
|
||||
params.append(f"%{model_filter}%")
|
||||
|
||||
where_clause = " AND ".join(where)
|
||||
|
||||
# Count total matching
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM response_audit WHERE {where_clause}",
|
||||
params,
|
||||
).fetchone()["cnt"]
|
||||
|
||||
# Fetch page — exclude large text fields for list view
|
||||
rows = conn.execute(
|
||||
f"""SELECT id, timestamp, agent, model, query,
|
||||
prompt_tokens, completion_tokens,
|
||||
generation_cost, embedding_cost, total_cost,
|
||||
confidence_score, response_time_ms, query_type,
|
||||
CASE WHEN tool_calls IS NOT NULL AND tool_calls != '[]'
|
||||
THEN json_array_length(tool_calls)
|
||||
ELSE 0 END as tool_call_count,
|
||||
LENGTH(display_response) as response_length
|
||||
FROM response_audit
|
||||
WHERE {where_clause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
params + [limit, offset],
|
||||
).fetchall()
|
||||
|
||||
responses = []
|
||||
for r in rows:
|
||||
responses.append({
|
||||
"id": r["id"],
|
||||
"timestamp": r["timestamp"],
|
||||
"agent": r["agent"],
|
||||
"model": r["model"],
|
||||
"query": r["query"],
|
||||
"query_type": r["query_type"],
|
||||
"prompt_tokens": r["prompt_tokens"],
|
||||
"completion_tokens": r["completion_tokens"],
|
||||
"generation_cost": r["generation_cost"],
|
||||
"embedding_cost": r["embedding_cost"],
|
||||
"total_cost": r["total_cost"],
|
||||
"confidence": r["confidence_score"],
|
||||
"response_time_ms": r["response_time_ms"],
|
||||
"tool_call_count": r["tool_call_count"],
|
||||
"response_length": r["response_length"],
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"hours": hours,
|
||||
"responses": responses,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── GET /api/response-audit/{id} ────────────────────────────────────────
|
||||
|
||||
async def handle_response_audit_detail(request):
|
||||
"""Full response detail including reasoning trace and tool calls.
|
||||
|
||||
Returns the complete response_audit row with tool_calls parsed as JSON.
|
||||
"""
|
||||
try:
|
||||
audit_id = int(request.match_info["id"])
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response({"error": "Invalid ID"}, status=400)
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""SELECT id, timestamp, chat_id, user, agent, model,
|
||||
query, query_type, conversation_window,
|
||||
entities_matched, claims_matched,
|
||||
retrieval_layers_hit, retrieval_gap,
|
||||
market_data, research_context,
|
||||
tool_calls, raw_response, display_response,
|
||||
confidence_score, response_time_ms,
|
||||
prompt_tokens, completion_tokens,
|
||||
generation_cost, embedding_cost, total_cost,
|
||||
blocked, block_reason
|
||||
FROM response_audit WHERE id = ?""",
|
||||
(audit_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return web.json_response({"error": "Response not found"}, status=404)
|
||||
|
||||
# Parse JSON fields
|
||||
def parse_json(val):
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return val
|
||||
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"timestamp": row["timestamp"],
|
||||
"chat_id": row["chat_id"],
|
||||
"user": row["user"],
|
||||
"agent": row["agent"],
|
||||
"model": row["model"],
|
||||
"query": row["query"],
|
||||
"query_type": row["query_type"],
|
||||
"conversation_window": parse_json(row["conversation_window"]),
|
||||
"entities_matched": parse_json(row["entities_matched"]),
|
||||
"claims_matched": parse_json(row["claims_matched"]),
|
||||
"retrieval_layers_hit": parse_json(row["retrieval_layers_hit"]),
|
||||
"retrieval_gap": row["retrieval_gap"],
|
||||
"market_data": parse_json(row["market_data"]),
|
||||
"research_context": row["research_context"],
|
||||
"tool_calls": parse_json(row["tool_calls"]),
|
||||
"display_response": row["display_response"],
|
||||
"raw_response": row["raw_response"],
|
||||
"confidence_score": row["confidence_score"],
|
||||
"response_time_ms": row["response_time_ms"],
|
||||
"prompt_tokens": row["prompt_tokens"],
|
||||
"completion_tokens": row["completion_tokens"],
|
||||
"generation_cost": row["generation_cost"],
|
||||
"embedding_cost": row["embedding_cost"],
|
||||
"total_cost": row["total_cost"],
|
||||
"blocked": bool(row["blocked"]) if row["blocked"] is not None else None,
|
||||
"block_reason": row["block_reason"],
|
||||
}
|
||||
|
||||
# Compute iteration summary from tool_calls
|
||||
tool_calls = result["tool_calls"] or []
|
||||
if isinstance(tool_calls, list):
|
||||
reasoning_steps = [t for t in tool_calls if isinstance(t, dict) and t.get("type") == "reasoning"]
|
||||
tool_steps = [t for t in tool_calls if isinstance(t, dict) and t.get("type") == "tool_call"]
|
||||
result["trace_summary"] = {
|
||||
"total_steps": len(tool_calls),
|
||||
"reasoning_steps": len(reasoning_steps),
|
||||
"tool_steps": len(tool_steps),
|
||||
"tools_used": list({t.get("tool", "unknown") for t in tool_steps}),
|
||||
"total_duration_ms": sum(t.get("duration_ms", 0) for t in tool_steps),
|
||||
}
|
||||
else:
|
||||
result["trace_summary"] = None
|
||||
|
||||
return web.json_response(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── GET /api/agent-costs ─────────────────────────────────────────────────
|
||||
|
||||
async def handle_agent_costs(request):
|
||||
"""Aggregated agent cost data from response_audit.
|
||||
|
||||
Query params:
|
||||
days — lookback window (default 7, max 30)
|
||||
by — grouping: agent, model, day (default agent)
|
||||
"""
|
||||
try:
|
||||
days = min(int(request.query.get("days", 7)), 30)
|
||||
except (ValueError, TypeError):
|
||||
days = 7
|
||||
group_by = request.query.get("by", "agent")
|
||||
agent = request.query.get("agent")
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
if group_by == "model":
|
||||
group_col = "model"
|
||||
elif group_by == "day":
|
||||
group_col = "date(timestamp)"
|
||||
else:
|
||||
group_col = "agent"
|
||||
group_by = "agent"
|
||||
|
||||
where = ["timestamp > datetime('now', ?)"]
|
||||
params: list = [f"-{days} days"]
|
||||
if agent:
|
||||
where.append("agent = ?")
|
||||
params.append(agent)
|
||||
|
||||
where_clause = " AND ".join(where)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT {group_col} as grp,
|
||||
COUNT(*) as responses,
|
||||
SUM(prompt_tokens) as total_prompt_tokens,
|
||||
SUM(completion_tokens) as total_completion_tokens,
|
||||
SUM(COALESCE(total_cost, generation_cost, 0)) as total_cost,
|
||||
AVG(COALESCE(total_cost, generation_cost, 0)) as avg_cost,
|
||||
AVG(response_time_ms) as avg_response_ms,
|
||||
AVG(confidence_score) as avg_confidence
|
||||
FROM response_audit
|
||||
WHERE {where_clause}
|
||||
GROUP BY grp
|
||||
ORDER BY total_cost DESC""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
breakdown = []
|
||||
for r in rows:
|
||||
breakdown.append({
|
||||
group_by: r["grp"],
|
||||
"responses": r["responses"],
|
||||
"prompt_tokens": r["total_prompt_tokens"] or 0,
|
||||
"completion_tokens": r["total_completion_tokens"] or 0,
|
||||
"total_cost": round(r["total_cost"] or 0, 4),
|
||||
"avg_cost_per_response": round(r["avg_cost"] or 0, 4),
|
||||
"avg_response_ms": round(r["avg_response_ms"] or 0, 0),
|
||||
"avg_confidence": round(r["avg_confidence"] or 0, 3) if r["avg_confidence"] else None,
|
||||
})
|
||||
|
||||
grand_total = sum(b["total_cost"] for b in breakdown)
|
||||
total_responses = sum(b["responses"] for b in breakdown)
|
||||
|
||||
# Daily trend (always included regardless of grouping)
|
||||
daily_where = ["timestamp > datetime('now', ?)"]
|
||||
daily_params: list = [f"-{days} days"]
|
||||
if agent:
|
||||
daily_where.append("agent = ?")
|
||||
daily_params.append(agent)
|
||||
|
||||
daily = conn.execute(
|
||||
f"""SELECT date(timestamp) as day,
|
||||
COUNT(*) as responses,
|
||||
SUM(COALESCE(total_cost, generation_cost, 0)) as cost
|
||||
FROM response_audit
|
||||
WHERE {' AND '.join(daily_where)}
|
||||
GROUP BY day ORDER BY day""",
|
||||
daily_params,
|
||||
).fetchall()
|
||||
|
||||
daily_trend = [
|
||||
{"date": r["day"], "responses": r["responses"],
|
||||
"cost": round(r["cost"] or 0, 4)}
|
||||
for r in daily
|
||||
]
|
||||
|
||||
return web.json_response({
|
||||
"period_days": days,
|
||||
"grand_total": round(grand_total, 4),
|
||||
"total_responses": total_responses,
|
||||
"avg_cost_per_response": round(grand_total / total_responses, 4) if total_responses else 0,
|
||||
f"by_{group_by}": breakdown,
|
||||
"daily_trend": daily_trend,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── GET /api/unified-activity ────────────────────────────────────────────
|
||||
|
||||
async def handle_unified_activity(request):
|
||||
"""Unified activity feed merging pipeline ops (prs) + agent responses (response_audit).
|
||||
|
||||
Query params:
|
||||
hours — lookback window (default 24, max 168)
|
||||
limit — max results (default 100, max 500)
|
||||
agent — filter by agent name
|
||||
type — filter: pipeline, response, or all (default all)
|
||||
"""
|
||||
try:
|
||||
hours = min(int(request.query.get("hours", 24)), 168)
|
||||
except (ValueError, TypeError):
|
||||
hours = 24
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", 100)), 500)
|
||||
except (ValueError, TypeError):
|
||||
limit = 100
|
||||
agent = request.query.get("agent")
|
||||
activity_type = request.query.get("type", "all")
|
||||
|
||||
conn = _conn(request.app)
|
||||
try:
|
||||
entries = []
|
||||
|
||||
# Pipeline events from prs table
|
||||
if activity_type in ("all", "pipeline"):
|
||||
pr_where = ["COALESCE(merged_at, created_at) > datetime('now', ?)"]
|
||||
pr_params: list = [f"-{hours} hours"]
|
||||
if agent:
|
||||
pr_where.append("agent = ?")
|
||||
pr_params.append(agent)
|
||||
|
||||
prs = conn.execute(
|
||||
f"""SELECT number, branch, status, domain, agent, tier,
|
||||
commit_type, cost_usd,
|
||||
created_at, merged_at,
|
||||
leo_verdict, domain_verdict
|
||||
FROM prs
|
||||
WHERE {' AND '.join(pr_where)}
|
||||
ORDER BY COALESCE(merged_at, created_at) DESC""",
|
||||
pr_params,
|
||||
).fetchall()
|
||||
|
||||
for pr in prs:
|
||||
ts = pr["merged_at"] or pr["created_at"]
|
||||
# Derive action description from status
|
||||
if pr["status"] == "merged":
|
||||
action = f"Merged {pr['commit_type'] or 'PR'}"
|
||||
elif pr["status"] == "closed":
|
||||
action = f"Closed {pr['commit_type'] or 'PR'}"
|
||||
elif pr["status"] in ("approved", "reviewing"):
|
||||
action = f"{pr['commit_type'] or 'PR'} awaiting merge"
|
||||
else:
|
||||
action = f"{pr['commit_type'] or 'PR'} {pr['status']}"
|
||||
|
||||
entries.append({
|
||||
"timestamp": ts,
|
||||
"type": "pipeline",
|
||||
"agent": pr["agent"],
|
||||
"action": action,
|
||||
"domain": pr["domain"],
|
||||
"pr_number": pr["number"],
|
||||
"branch": pr["branch"],
|
||||
"status": pr["status"],
|
||||
"commit_type": pr["commit_type"],
|
||||
"cost": pr["cost_usd"],
|
||||
"detail": {
|
||||
"tier": pr["tier"],
|
||||
"leo_verdict": pr["leo_verdict"],
|
||||
"domain_verdict": pr["domain_verdict"],
|
||||
},
|
||||
})
|
||||
|
||||
# Agent responses from response_audit
|
||||
if activity_type in ("all", "response"):
|
||||
ra_where = ["timestamp > datetime('now', ?)"]
|
||||
ra_params: list = [f"-{hours} hours"]
|
||||
if agent:
|
||||
ra_where.append("agent = ?")
|
||||
ra_params.append(agent)
|
||||
|
||||
responses = conn.execute(
|
||||
f"""SELECT id, timestamp, agent, model, query,
|
||||
generation_cost, response_time_ms,
|
||||
confidence_score,
|
||||
CASE WHEN tool_calls IS NOT NULL AND tool_calls != '[]'
|
||||
THEN json_array_length(tool_calls)
|
||||
ELSE 0 END as tool_call_count
|
||||
FROM response_audit
|
||||
WHERE {' AND '.join(ra_where)}
|
||||
ORDER BY timestamp DESC""",
|
||||
ra_params,
|
||||
).fetchall()
|
||||
|
||||
for r in responses:
|
||||
# Truncate query for feed display
|
||||
query_preview = (r["query"] or "")[:120]
|
||||
if len(r["query"] or "") > 120:
|
||||
query_preview += "..."
|
||||
|
||||
entries.append({
|
||||
"timestamp": r["timestamp"],
|
||||
"type": "response",
|
||||
"agent": r["agent"],
|
||||
"action": f"Responded to query ({r['tool_call_count']} tool calls)",
|
||||
"domain": None,
|
||||
"pr_number": None,
|
||||
"audit_id": r["id"],
|
||||
"query_preview": query_preview,
|
||||
"model": r["model"],
|
||||
"cost": r["generation_cost"],
|
||||
"detail": {
|
||||
"response_time_ms": r["response_time_ms"],
|
||||
"confidence": r["confidence_score"],
|
||||
"tool_call_count": r["tool_call_count"],
|
||||
},
|
||||
})
|
||||
|
||||
# Sort combined entries by timestamp descending
|
||||
entries.sort(key=lambda e: e["timestamp"] or "", reverse=True)
|
||||
entries = entries[:limit]
|
||||
|
||||
# Summary stats
|
||||
pipeline_count = sum(1 for e in entries if e["type"] == "pipeline")
|
||||
response_count = sum(1 for e in entries if e["type"] == "response")
|
||||
total_cost = sum(e.get("cost") or 0 for e in entries)
|
||||
|
||||
return web.json_response({
|
||||
"hours": hours,
|
||||
"total_entries": len(entries),
|
||||
"pipeline_events": pipeline_count,
|
||||
"response_events": response_count,
|
||||
"total_cost": round(total_cost, 4),
|
||||
"entries": entries,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Registration ─────────────────────────────────────────────────────────
|
||||
|
||||
def register_response_audit_routes(app):
|
||||
"""Register response audit API routes. Call from create_app()."""
|
||||
app.router.add_get("/api/response-audit", handle_response_audit_list)
|
||||
app.router.add_get("/api/response-audit/{id}", handle_response_audit_detail)
|
||||
app.router.add_get("/api/agent-costs", handle_agent_costs)
|
||||
app.router.add_get("/api/unified-activity", handle_unified_activity)
|
||||
|
||||
|
||||
# Public paths for auth middleware
|
||||
RESPONSE_AUDIT_PUBLIC_PATHS = frozenset({
|
||||
"/api/response-audit",
|
||||
"/api/agent-costs",
|
||||
"/api/unified-activity",
|
||||
})
|
||||
# /api/response-audit/{id} needs prefix matching in auth middleware
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
"""Review queue: fetches open PRs from Forgejo, classifies and enriches them.
|
||||
|
||||
Data sources:
|
||||
- Forgejo API (git.livingip.xyz) for PR metadata, reviews, changed files
|
||||
- pipeline.db prs table for eval status cross-reference
|
||||
|
||||
Display priority: broken > needs-review (by age) > approved-awaiting-merge > changes-requested
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger("argus.review_queue")
|
||||
|
||||
FORGEJO_BASE = "https://git.livingip.xyz/api/v1"
|
||||
REPO = "teleo/teleo-codex"
|
||||
|
||||
# Domain detection from branch prefixes or path patterns
|
||||
DOMAIN_KEYWORDS = {
|
||||
"internet-finance": ["internet-finance", "defi", "dao", "prediction-market"],
|
||||
"entertainment": ["entertainment", "clay", "media", "ip-"],
|
||||
"ai-alignment": ["ai-alignment", "alignment", "theseus"],
|
||||
"health": ["health", "vida", "biotech", "glp"],
|
||||
"space-development": ["space", "astra", "orbital", "lunar"],
|
||||
"energy": ["energy", "solar", "nuclear", "fusion"],
|
||||
"grand-strategy": ["grand-strategy", "leo", "strategy"],
|
||||
"collective-intelligence": ["collective-intelligence", "coordination"],
|
||||
"critical-systems": ["critical-systems", "complexity", "emergence"],
|
||||
"teleological-economics": ["teleological-economics", "disruption", "attractor"],
|
||||
"cultural-dynamics": ["cultural-dynamics", "memetics", "narrative"],
|
||||
"mechanisms": ["mechanisms", "futarchy", "governance"],
|
||||
"living-capital": ["living-capital", "investment"],
|
||||
"living-agents": ["living-agents", "agent-architecture"],
|
||||
"teleohumanity": ["teleohumanity", "worldview"],
|
||||
"general": ["general"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_domain(branch: str, title: str, files: list[dict]) -> str:
|
||||
"""Detect domain from branch name, title, or changed file paths."""
|
||||
text = f"{branch} {title}".lower()
|
||||
|
||||
# Check branch/title
|
||||
for domain, keywords in DOMAIN_KEYWORDS.items():
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
return domain
|
||||
|
||||
# Check file paths
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if path.startswith("domains/") or path.startswith("foundations/") or path.startswith("core/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _classify_files(files: list[dict]) -> dict[str, int]:
|
||||
"""Count claim, enrichment, and challenge files from changed files list."""
|
||||
counts = {"claim_count": 0, "enrichment_count": 0, "challenge_count": 0}
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
status = f.get("status", "") # added, modified, removed
|
||||
|
||||
if not path.startswith("domains/") and not path.startswith("foundations/") and not path.startswith("core/"):
|
||||
continue
|
||||
|
||||
name = path.split("/")[-1].lower()
|
||||
|
||||
if "challenge" in name or "divergence" in name:
|
||||
counts["challenge_count"] += 1
|
||||
elif status == "modified":
|
||||
counts["enrichment_count"] += 1
|
||||
else:
|
||||
counts["claim_count"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def _classify_status(
|
||||
changed_files: int,
|
||||
reviews: list[dict],
|
||||
requested_reviewers: list[dict],
|
||||
) -> str:
|
||||
"""Classify PR status: broken, needs-review, approved-awaiting-merge, changes-requested."""
|
||||
if changed_files == 0:
|
||||
return "broken"
|
||||
|
||||
has_changes_requested = any(r["state"] == "REQUEST_CHANGES" for r in reviews)
|
||||
if has_changes_requested:
|
||||
# Check if there's a newer approval after the changes request
|
||||
last_change_req = max(
|
||||
(r["submitted_at"] for r in reviews if r["state"] == "REQUEST_CHANGES"),
|
||||
default="",
|
||||
)
|
||||
later_approvals = [
|
||||
r for r in reviews
|
||||
if r["state"] == "APPROVED" and r["submitted_at"] > last_change_req
|
||||
]
|
||||
if not later_approvals:
|
||||
return "changes-requested"
|
||||
|
||||
approvals = [r for r in reviews if r["state"] == "APPROVED"]
|
||||
if len(approvals) >= 2:
|
||||
return "approved-awaiting-merge"
|
||||
|
||||
return "needs-review"
|
||||
|
||||
|
||||
def _days_open(created_at: str) -> int:
|
||||
"""Calculate days since PR was opened."""
|
||||
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
return (now - created).days
|
||||
|
||||
|
||||
_STATUS_PRIORITY = {
|
||||
"broken": 0,
|
||||
"needs-review": 1,
|
||||
"approved-awaiting-merge": 2,
|
||||
"changes-requested": 3,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_review_queue(
|
||||
forgejo_token: str | None = None,
|
||||
timeout_s: int = 15,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch open PRs from Forgejo and return enriched review queue.
|
||||
|
||||
Returns list sorted by display priority (broken first, then needs-review by age).
|
||||
"""
|
||||
headers = {"Accept": "application/json"}
|
||||
if forgejo_token:
|
||||
headers["Authorization"] = f"token {forgejo_token}"
|
||||
|
||||
connector = aiohttp.TCPConnector() # Default SSL verification — Forgejo token must not be exposed to MITM
|
||||
async with aiohttp.ClientSession(headers=headers, connector=connector) as session:
|
||||
# Fetch open PRs
|
||||
url = f"{FORGEJO_BASE}/repos/{REPO}/pulls?state=open&limit=50&sort=oldest"
|
||||
try:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout_s)) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error("Forgejo PR list returned %d", resp.status)
|
||||
return []
|
||||
prs = await resp.json()
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch PRs from Forgejo: %s", e)
|
||||
return []
|
||||
|
||||
# Fetch reviews and files for all PRs in parallel
|
||||
async def _fetch_json(session, url, label=""):
|
||||
try:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout_s)) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch %s: %s", label, e)
|
||||
return []
|
||||
|
||||
sub_tasks = []
|
||||
for pr in prs:
|
||||
n = pr["number"]
|
||||
sub_tasks.append(_fetch_json(session, f"{FORGEJO_BASE}/repos/{REPO}/pulls/{n}/reviews", f"reviews PR#{n}"))
|
||||
sub_tasks.append(_fetch_json(session, f"{FORGEJO_BASE}/repos/{REPO}/pulls/{n}/files", f"files PR#{n}"))
|
||||
|
||||
sub_results = await asyncio.gather(*sub_tasks)
|
||||
|
||||
queue = []
|
||||
for i, pr in enumerate(prs):
|
||||
reviews = sub_results[i * 2]
|
||||
files = sub_results[i * 2 + 1]
|
||||
|
||||
# Build enriched PR record
|
||||
branch = pr.get("head", {}).get("ref", "") if pr.get("head") else ""
|
||||
title = pr.get("title", "")
|
||||
author = pr.get("user", {}).get("login", "unknown")
|
||||
created_at = pr.get("created_at", "")
|
||||
changed_files = pr.get("changed_files", len(files))
|
||||
requested_reviewers = pr.get("requested_reviewers", [])
|
||||
|
||||
domain = _detect_domain(branch, title, files)
|
||||
file_counts = _classify_files(files)
|
||||
status = _classify_status(changed_files, reviews, requested_reviewers)
|
||||
days = _days_open(created_at) if created_at else 0
|
||||
|
||||
review_list = [
|
||||
{
|
||||
"reviewer": r.get("user", {}).get("login", "unknown"),
|
||||
"outcome": r.get("state", "PENDING").lower(),
|
||||
"date": r.get("submitted_at", ""),
|
||||
"summary": r.get("body", "")[:200],
|
||||
}
|
||||
for r in reviews
|
||||
if r.get("state") and r["state"] != "PENDING"
|
||||
]
|
||||
|
||||
queue.append({
|
||||
"pr_number": pr["number"],
|
||||
"title": title,
|
||||
"author": author,
|
||||
"domain": domain,
|
||||
"branch": branch,
|
||||
"created_at": created_at,
|
||||
"days_open": days,
|
||||
"status": status,
|
||||
"changed_files": changed_files,
|
||||
**file_counts,
|
||||
"reviews": review_list,
|
||||
"url": pr.get("html_url", ""),
|
||||
})
|
||||
|
||||
# Sort: broken first, then needs-review by days_open desc, then rest
|
||||
queue.sort(key=lambda x: (_STATUS_PRIORITY.get(x["status"], 99), -x["days_open"]))
|
||||
|
||||
return queue
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""Route handlers for /api/review-queue endpoint.
|
||||
|
||||
Import into app.py and register routes in create_app().
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from review_queue import fetch_review_queue
|
||||
|
||||
logger = logging.getLogger("argus.review_queue")
|
||||
|
||||
|
||||
async def handle_review_queue(request):
|
||||
"""GET /api/review-queue — PR review pipeline view.
|
||||
|
||||
Query params:
|
||||
status: filter by status (broken, needs-review, approved-awaiting-merge, changes-requested)
|
||||
author: filter by agent/author name
|
||||
domain: filter by domain
|
||||
|
||||
Returns JSON with queue items sorted by display priority:
|
||||
broken (flagged) > needs-review (by age) > approved-awaiting-merge
|
||||
"""
|
||||
token = request.app.get("_forgejo_token")
|
||||
|
||||
try:
|
||||
queue = await fetch_review_queue(forgejo_token=token)
|
||||
except Exception as e:
|
||||
logger.error("Review queue fetch failed: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.query.get("status")
|
||||
if status_filter:
|
||||
queue = [item for item in queue if item["status"] == status_filter]
|
||||
|
||||
author_filter = request.query.get("author")
|
||||
if author_filter:
|
||||
queue = [item for item in queue if item["author"] == author_filter]
|
||||
|
||||
domain_filter = request.query.get("domain")
|
||||
if domain_filter:
|
||||
queue = [item for item in queue if item["domain"] == domain_filter]
|
||||
|
||||
# Summary stats
|
||||
status_counts = {}
|
||||
for item in queue:
|
||||
status_counts[item["status"]] = status_counts.get(item["status"], 0) + 1
|
||||
|
||||
return web.json_response({
|
||||
"queue": queue,
|
||||
"total": len(queue),
|
||||
"status_counts": status_counts,
|
||||
})
|
||||
|
||||
|
||||
def register_review_queue_routes(app, forgejo_token=None):
|
||||
"""Register review queue routes on the app.
|
||||
|
||||
forgejo_token: optional Forgejo API token for authenticated requests
|
||||
"""
|
||||
app["_forgejo_token"] = forgejo_token
|
||||
app.router.add_get("/api/review-queue", handle_review_queue)
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"""Shared UI components for the 4-page Argus dashboard.
|
||||
|
||||
Provides: nav bar, CSS, page skeleton, Chart.js imports, shared JS helpers.
|
||||
All pages import render_page() and pass their body HTML + page-specific scripts.
|
||||
"""
|
||||
|
||||
# Page definitions — used by nav bar
|
||||
PAGES = [
|
||||
{"path": "/prs", "label": "PRs", "icon": "✎"},
|
||||
{"path": "/ops", "label": "Operations", "icon": "⚙"},
|
||||
{"path": "/health", "label": "Knowledge Health", "icon": "♥"},
|
||||
{"path": "/kb-proposals", "label": "KB Proposals", "icon": "⚒"},
|
||||
{"path": "/agents", "label": "Agents", "icon": "★"},
|
||||
{"path": "/epistemic", "label": "Epistemic", "icon": "⚖"},
|
||||
{"path": "/portfolio", "label": "Portfolio", "icon": "★"},
|
||||
]
|
||||
|
||||
|
||||
def _nav_html(active_path: str) -> str:
|
||||
"""Render the shared navigation bar."""
|
||||
links = []
|
||||
for p in PAGES:
|
||||
cls = "nav-active" if p["path"] == active_path else ""
|
||||
links.append(
|
||||
f'<a href="{p["path"]}" class="nav-link {cls}">'
|
||||
f'{p["icon"]} {p["label"]}</a>'
|
||||
)
|
||||
return f"""<nav class="top-nav">
|
||||
<div class="nav-brand">Argus</div>
|
||||
<div class="nav-links">{"".join(links)}</div>
|
||||
<div class="nav-aux">
|
||||
<a href="/audit" class="nav-link">Audit</a>
|
||||
<a href="/api/metrics" class="nav-link">API</a>
|
||||
</div>
|
||||
</nav>"""
|
||||
|
||||
|
||||
SHARED_CSS = """
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; }
|
||||
.top-nav { display: flex; align-items: center; gap: 16px; padding: 12px 24px;
|
||||
background: #161b22; border-bottom: 1px solid #30363d; position: sticky; top: 0; z-index: 100; }
|
||||
.nav-brand { color: #58a6ff; font-weight: 700; font-size: 18px; }
|
||||
.nav-links { display: flex; gap: 4px; flex: 1; }
|
||||
.nav-aux { display: flex; gap: 4px; }
|
||||
.nav-link { color: #8b949e; text-decoration: none; padding: 6px 12px; border-radius: 6px;
|
||||
font-size: 13px; transition: all 0.15s; white-space: nowrap; }
|
||||
.nav-link:hover { color: #c9d1d9; background: #21262d; }
|
||||
.nav-active { color: #58a6ff !important; background: #0d1117; font-weight: 600; }
|
||||
.page-content { padding: 24px; max-width: 1400px; margin: 0 auto; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { color: #58a6ff; font-size: 22px; }
|
||||
.page-header .subtitle { color: #8b949e; font-size: 13px; margin-top: 4px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||||
.card .label { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.card .value { font-size: 28px; font-weight: 700; margin-top: 2px; }
|
||||
.card .detail { color: #8b949e; font-size: 11px; margin-top: 2px; }
|
||||
.green { color: #3fb950; }
|
||||
.yellow { color: #d29922; }
|
||||
.red { color: #f85149; }
|
||||
.blue { color: #58a6ff; }
|
||||
.purple { color: #bc8cff; }
|
||||
.chart-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin: 16px 0; }
|
||||
.chart-container h2 { color: #c9d1d9; font-size: 14px; margin-bottom: 12px; }
|
||||
canvas { max-height: 260px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 800px) { .row { grid-template-columns: 1fr; } }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { color: #8b949e; font-size: 11px; text-transform: uppercase; text-align: left; padding: 6px 10px; border-bottom: 1px solid #30363d; }
|
||||
td { padding: 6px 10px; border-bottom: 1px solid #21262d; }
|
||||
code { background: #21262d; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
|
||||
.section { margin-top: 28px; }
|
||||
.section-title { color: #58a6ff; font-size: 15px; font-weight: 600; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid #21262d; }
|
||||
.funnel { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.funnel-step { text-align: center; flex: 1; min-width: 100px; }
|
||||
.funnel-step .num { font-size: 24px; font-weight: 700; }
|
||||
.funnel-step .lbl { font-size: 11px; color: #8b949e; text-transform: uppercase; }
|
||||
.funnel-arrow { color: #30363d; font-size: 20px; }
|
||||
.footer { margin-top: 40px; padding: 16px 24px; border-top: 1px solid #21262d; color: #484f58; font-size: 11px; text-align: center; }
|
||||
.footer a { color: #484f58; text-decoration: none; }
|
||||
.footer a:hover { color: #8b949e; }
|
||||
.alert-banner { padding: 8px 16px; font-size: 12px; border-radius: 6px; margin-bottom: 12px; }
|
||||
.alert-critical { background: #f8514922; border: 1px solid #f85149; color: #f85149; }
|
||||
.alert-warning { background: #d2992222; border: 1px solid #d29922; color: #d29922; }
|
||||
.alert-info { background: #58a6ff22; border: 1px solid #58a6ff; color: #58a6ff; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-green { background: #23863633; color: #3fb950; }
|
||||
.badge-yellow { background: #d2992233; color: #d29922; }
|
||||
.badge-red { background: #f8514933; color: #f85149; }
|
||||
.badge-blue { background: #1f6feb33; color: #58a6ff; }
|
||||
"""
|
||||
|
||||
|
||||
CHART_JS_IMPORTS = """<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.1.0"></script>"""
|
||||
|
||||
|
||||
SHARED_JS = """
|
||||
const AGENT_COLORS = {
|
||||
'rio': '#58a6ff', 'clay': '#3fb950', 'astra': '#bc8cff',
|
||||
'leo': '#d29922', 'vida': '#f0883e', 'theseus': '#f85149',
|
||||
'epimetheus': '#79c0ff', 'ganymede': '#8b949e', 'oberon': '#ec4899',
|
||||
};
|
||||
function agentColor(name) {
|
||||
return AGENT_COLORS[name?.toLowerCase()] ||
|
||||
'#' + ((name||'').split('').reduce((a,c) => (a*31+c.charCodeAt(0))&0xFFFFFF, 0x556677)).toString(16).padStart(6,'0');
|
||||
}
|
||||
Chart.defaults.color = '#8b949e';
|
||||
Chart.defaults.borderColor = '#21262d';
|
||||
Chart.defaults.font.family = '-apple-system, system-ui, sans-serif';
|
||||
Chart.defaults.font.size = 11;
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmtPct(v) { return v != null ? (v * 100).toFixed(1) + '%' : '--'; }
|
||||
function fmtNum(v) { return v != null ? v.toLocaleString() : '--'; }
|
||||
function fmtDollars(v) { return v != null ? '$' + v.toFixed(2) : '--'; }
|
||||
"""
|
||||
|
||||
|
||||
def render_page(title: str, subtitle: str, active_path: str, body_html: str,
|
||||
scripts: str = "", extra_css: str = "", timestamp: str = "") -> str:
|
||||
"""Render a complete page with nav, content, and footer."""
|
||||
ts_display = f" · {timestamp}" if timestamp else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8">
|
||||
<title>Argus - {title}</title>
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{CHART_JS_IMPORTS}
|
||||
<style>{SHARED_CSS}{extra_css}</style>
|
||||
</head><body>
|
||||
{_nav_html(active_path)}
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h1>{title}</h1>
|
||||
<div class="subtitle">{subtitle}{ts_display} · auto-refresh 60s</div>
|
||||
</div>
|
||||
{body_html}
|
||||
</div>
|
||||
<div class="footer">
|
||||
Argus · Teleo Pipeline Diagnostics ·
|
||||
<a href="/api/metrics">Metrics API</a> ·
|
||||
<a href="/api/vital-signs">Vital Signs API</a> ·
|
||||
<a href="/api/contributors">Contributors API</a>
|
||||
</div>
|
||||
<script>{SHARED_JS}</script>
|
||||
{scripts}
|
||||
</body></html>"""
|
||||
|
|
@ -1,476 +0,0 @@
|
|||
"""Tier 1 Metrics — The three numbers that matter most for knowledge production.
|
||||
|
||||
1. Extraction yield: claims merged / claims evaluated, per agent, per week
|
||||
2. Cost per merged claim: total spend / merged claims, per week
|
||||
3. Fix success rate by rejection tag: which rejection reasons are fixable vs terminal
|
||||
|
||||
These queries run against pipeline.db (read-only) and power the /api/yield,
|
||||
/api/cost-per-claim, and /api/fix-rates endpoints.
|
||||
|
||||
Owner: Argus <69AF7290-758F-464B-B472-04AFCA4AB340>
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def extraction_yield(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Extraction yield = merged / evaluated, trended per agent per week.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"daily": [{"day": "2026-W13", "agent": "rio", "evaluated": 20, "merged": 8, "yield": 0.4}, ...],
|
||||
"totals": [{"agent": "rio", "evaluated": 100, "merged": 40, "yield": 0.4}, ...],
|
||||
"system": {"evaluated": 500, "merged": 200, "yield": 0.4}
|
||||
}
|
||||
"""
|
||||
# Weekly yield per agent
|
||||
# Uses strftime('%Y-W%W') for ISO week grouping
|
||||
# evaluated = approved + rejected (all terminal eval events)
|
||||
# merged = approved events only
|
||||
weekly = conn.execute(
|
||||
"""
|
||||
SELECT date(timestamp) as day,
|
||||
json_extract(detail, '$.agent') as agent,
|
||||
COUNT(*) as evaluated,
|
||||
SUM(CASE WHEN event = 'approved' THEN 1 ELSE 0 END) as merged
|
||||
FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
AND event IN ('approved', 'changes_requested', 'domain_rejected', 'tier05_rejected')
|
||||
AND timestamp > datetime('now', ? || ' days')
|
||||
GROUP BY day, agent
|
||||
ORDER BY day DESC, agent
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
daily_data = []
|
||||
for r in weekly:
|
||||
ev = r["evaluated"] or 0
|
||||
mg = r["merged"] or 0
|
||||
daily_data.append({
|
||||
"day": r["day"],
|
||||
"agent": r["agent"] or "unknown",
|
||||
"evaluated": ev,
|
||||
"merged": mg,
|
||||
"yield": round(mg / ev, 3) if ev else 0,
|
||||
})
|
||||
|
||||
# Per-agent totals (same window)
|
||||
totals = conn.execute(
|
||||
"""
|
||||
SELECT json_extract(detail, '$.agent') as agent,
|
||||
COUNT(*) as evaluated,
|
||||
SUM(CASE WHEN event = 'approved' THEN 1 ELSE 0 END) as merged
|
||||
FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
AND event IN ('approved', 'changes_requested', 'domain_rejected', 'tier05_rejected')
|
||||
AND timestamp > datetime('now', ? || ' days')
|
||||
GROUP BY agent
|
||||
ORDER BY merged DESC
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
totals_data = []
|
||||
for r in totals:
|
||||
ev = r["evaluated"] or 0
|
||||
mg = r["merged"] or 0
|
||||
totals_data.append({
|
||||
"agent": r["agent"] or "unknown",
|
||||
"evaluated": ev,
|
||||
"merged": mg,
|
||||
"yield": round(mg / ev, 3) if ev else 0,
|
||||
})
|
||||
|
||||
# System-wide total
|
||||
sys_row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as evaluated,
|
||||
SUM(CASE WHEN event = 'approved' THEN 1 ELSE 0 END) as merged
|
||||
FROM audit_log
|
||||
WHERE stage = 'evaluate'
|
||||
AND event IN ('approved', 'changes_requested', 'domain_rejected', 'tier05_rejected')
|
||||
AND timestamp > datetime('now', ? || ' days')
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchone()
|
||||
|
||||
sys_ev = sys_row["evaluated"] or 0
|
||||
sys_mg = sys_row["merged"] or 0
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"daily": daily_data,
|
||||
"totals": totals_data,
|
||||
"system": {
|
||||
"evaluated": sys_ev,
|
||||
"merged": sys_mg,
|
||||
"yield": round(sys_mg / sys_ev, 3) if sys_ev else 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def cost_per_merged_claim(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Cost and compute per merged claim, trended per week.
|
||||
|
||||
Uses costs table for spend + tokens and prs table for merge counts.
|
||||
Breaks down by stage. Separates API spend (dollars) from subscription
|
||||
compute (tokens only — Claude Max is flat-rate, so dollars are meaningless).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"daily": [{"day": "2026-W13", "api_cost": 1.50, "merged": 8,
|
||||
"cost_per_claim": 0.19, "input_tokens": 50000,
|
||||
"output_tokens": 5000, "total_tokens": 55000,
|
||||
"tokens_per_claim": 6875}, ...],
|
||||
"by_stage": [{"stage": "eval_leo:openrouter", "api_cost": 1.50,
|
||||
"input_tokens": 300000, "output_tokens": 50000,
|
||||
"calls": 100, "billing": "api"}, ...],
|
||||
"system": {"api_cost": 2.36, "merged": 80, "cost_per_claim": 0.03,
|
||||
"total_tokens": 1200000, "tokens_per_claim": 15000,
|
||||
"subscription_tokens": 0, "api_tokens": 1200000}
|
||||
}
|
||||
"""
|
||||
# Weekly: cost + tokens from costs table, merged count from prs table
|
||||
daily_cost = conn.execute(
|
||||
"""
|
||||
SELECT date as day,
|
||||
SUM(cost_usd) as api_cost,
|
||||
SUM(cost_estimate_usd) as estimated_cost,
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens
|
||||
FROM costs
|
||||
WHERE date > date('now', ? || ' days')
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
daily_merges = conn.execute(
|
||||
"""
|
||||
SELECT date(merged_at) as day,
|
||||
COUNT(*) as merged
|
||||
FROM prs
|
||||
WHERE status = 'merged'
|
||||
AND merged_at > datetime('now', ? || ' days')
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
# Merge into combined weekly view
|
||||
merge_map = {r["day"]: r["merged"] for r in daily_merges}
|
||||
cost_map = {}
|
||||
for r in daily_cost:
|
||||
cost_map[r["day"]] = {
|
||||
"api_cost": r["api_cost"] or 0,
|
||||
"estimated_cost": r["estimated_cost"] or 0,
|
||||
"input_tokens": r["input_tokens"] or 0,
|
||||
"output_tokens": r["output_tokens"] or 0,
|
||||
}
|
||||
|
||||
all_days = sorted(set(list(merge_map.keys()) + list(cost_map.keys())), reverse=True)
|
||||
daily_data = []
|
||||
for w in all_days:
|
||||
c = cost_map.get(w, {"api_cost": 0, "estimated_cost": 0, "input_tokens": 0, "output_tokens": 0})
|
||||
merged = merge_map.get(w, 0) or 0
|
||||
total_tokens = c["input_tokens"] + c["output_tokens"]
|
||||
daily_data.append({
|
||||
"day": w,
|
||||
"actual_spend": round(c["api_cost"], 4),
|
||||
"estimated_cost": round(c["estimated_cost"], 4),
|
||||
"merged": merged,
|
||||
"cost_per_claim": round(c["estimated_cost"] / merged, 4) if merged else None,
|
||||
"input_tokens": c["input_tokens"],
|
||||
"output_tokens": c["output_tokens"],
|
||||
"total_tokens": total_tokens,
|
||||
"tokens_per_claim": round(total_tokens / merged) if merged else None,
|
||||
})
|
||||
|
||||
# By stage with billing type (full window)
|
||||
by_stage = conn.execute(
|
||||
"""
|
||||
SELECT stage,
|
||||
SUM(cost_usd) as api_cost,
|
||||
SUM(cost_estimate_usd) as estimated_cost,
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens,
|
||||
SUM(calls) as calls
|
||||
FROM costs
|
||||
WHERE date > date('now', ? || ' days')
|
||||
GROUP BY stage
|
||||
ORDER BY SUM(input_tokens + output_tokens) DESC
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
stage_data = []
|
||||
total_api_cost = 0
|
||||
total_estimated_cost = 0
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
subscription_tokens = 0
|
||||
api_tokens = 0
|
||||
for r in by_stage:
|
||||
cost = r["api_cost"] or 0
|
||||
est = r["estimated_cost"] or 0
|
||||
inp = r["input_tokens"] or 0
|
||||
out = r["output_tokens"] or 0
|
||||
calls = r["calls"] or 0
|
||||
stage_name = r["stage"]
|
||||
# :max suffix = subscription, :openrouter suffix = API
|
||||
billing = "subscription" if ":max" in stage_name else "api"
|
||||
total_api_cost += cost
|
||||
total_estimated_cost += est
|
||||
total_input += inp
|
||||
total_output += out
|
||||
if billing == "subscription":
|
||||
subscription_tokens += inp + out
|
||||
else:
|
||||
api_tokens += inp + out
|
||||
stage_data.append({
|
||||
"stage": stage_name,
|
||||
"api_cost": round(cost, 4),
|
||||
"estimated_cost": round(est, 4),
|
||||
"input_tokens": inp,
|
||||
"output_tokens": out,
|
||||
"calls": calls,
|
||||
"billing": billing,
|
||||
})
|
||||
|
||||
# System totals
|
||||
sys_merged = conn.execute(
|
||||
"SELECT COUNT(*) as n FROM prs WHERE status='merged' AND merged_at > datetime('now', ? || ' days')",
|
||||
(f"-{days}",),
|
||||
).fetchone()["n"] or 0
|
||||
|
||||
total_tokens = total_input + total_output
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"daily": daily_data,
|
||||
"by_stage": stage_data,
|
||||
"system": {
|
||||
"actual_spend": round(total_api_cost, 4),
|
||||
"estimated_cost": round(total_estimated_cost, 4),
|
||||
"merged": sys_merged,
|
||||
"cost_per_claim": round(total_estimated_cost / sys_merged, 4) if sys_merged else None,
|
||||
"total_tokens": total_tokens,
|
||||
"tokens_per_claim": round(total_tokens / sys_merged) if sys_merged else None,
|
||||
"subscription_tokens": subscription_tokens,
|
||||
"api_tokens": api_tokens,
|
||||
"note": "estimated_cost = API-rate equivalent for all calls (unified metric). actual_spend = real dollars charged to OpenRouter.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fix_success_by_tag(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Fix success rate broken down by rejection reason.
|
||||
|
||||
For each rejection tag: how many PRs got that rejection, how many eventually
|
||||
merged (successful fix), how many are still open (in progress), how many
|
||||
were abandoned (closed/zombie without merge).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"tag": "insufficient_evidence",
|
||||
"total": 50,
|
||||
"fixed": 10,
|
||||
"in_progress": 5,
|
||||
"terminal": 35,
|
||||
"fix_rate": 0.2,
|
||||
"terminal_rate": 0.7
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
"""
|
||||
# Get all rejection events with their tags and PR numbers
|
||||
# Then join with prs table to see final outcome
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT value as tag,
|
||||
json_extract(al.detail, '$.pr') as pr_number
|
||||
FROM audit_log al, json_each(json_extract(al.detail, '$.issues'))
|
||||
WHERE al.stage = 'evaluate'
|
||||
AND al.event IN ('changes_requested', 'domain_rejected', 'tier05_rejected')
|
||||
AND al.timestamp > datetime('now', ? || ' days')
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
# Collect unique PRs per tag
|
||||
tag_prs: dict[str, set] = {}
|
||||
for r in rows:
|
||||
tag = r["tag"]
|
||||
pr = r["pr_number"]
|
||||
if tag not in tag_prs:
|
||||
tag_prs[tag] = set()
|
||||
if pr is not None:
|
||||
tag_prs[tag].add(pr)
|
||||
|
||||
if not tag_prs:
|
||||
return {"days": days, "tags": []}
|
||||
|
||||
# Get status for all referenced PRs in one query
|
||||
all_prs = set()
|
||||
for prs in tag_prs.values():
|
||||
all_prs.update(prs)
|
||||
|
||||
if not all_prs:
|
||||
return {"days": days, "tags": []}
|
||||
|
||||
placeholders = ",".join("?" for _ in all_prs)
|
||||
pr_statuses = conn.execute(
|
||||
f"SELECT number, status FROM prs WHERE number IN ({placeholders})",
|
||||
list(all_prs),
|
||||
).fetchall()
|
||||
status_map = {r["number"]: r["status"] for r in pr_statuses}
|
||||
|
||||
# Compute per-tag outcomes
|
||||
tag_data = []
|
||||
for tag, prs in sorted(tag_prs.items(), key=lambda x: -len(x[1])):
|
||||
fixed = 0
|
||||
in_progress = 0
|
||||
terminal = 0
|
||||
for pr in prs:
|
||||
st = status_map.get(pr, "unknown")
|
||||
if st == "merged":
|
||||
fixed += 1
|
||||
elif st in ("open", "validating", "reviewing", "merging"):
|
||||
in_progress += 1
|
||||
else:
|
||||
# closed, zombie, conflict, unknown
|
||||
terminal += 1
|
||||
|
||||
total = len(prs)
|
||||
# Fix rate excludes in-progress (only counts resolved PRs)
|
||||
resolved = fixed + terminal
|
||||
tag_data.append({
|
||||
"tag": tag,
|
||||
"total": total,
|
||||
"fixed": fixed,
|
||||
"in_progress": in_progress,
|
||||
"terminal": terminal,
|
||||
"fix_rate": round(fixed / resolved, 3) if resolved else None,
|
||||
"terminal_rate": round(terminal / resolved, 3) if resolved else None,
|
||||
})
|
||||
|
||||
return {"days": days, "tags": tag_data}
|
||||
|
||||
|
||||
def compute_profile(conn: "sqlite3.Connection", days: int = 30) -> dict:
|
||||
"""Compute profile — Max subscription telemetry alongside API usage.
|
||||
|
||||
Surfaces: cache hit rates, latency, cost estimates (API-equivalent),
|
||||
token breakdown by billing type.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT stage, model,
|
||||
SUM(calls) as calls,
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens,
|
||||
SUM(cost_usd) as api_cost,
|
||||
SUM(duration_ms) as duration_ms,
|
||||
SUM(cache_read_tokens) as cache_read_tokens,
|
||||
SUM(cache_write_tokens) as cache_write_tokens,
|
||||
SUM(cost_estimate_usd) as cost_estimate_usd
|
||||
FROM costs
|
||||
WHERE date > date('now', ? || ' days')
|
||||
GROUP BY stage, model
|
||||
ORDER BY SUM(input_tokens + output_tokens) DESC
|
||||
""",
|
||||
(f"-{days}",),
|
||||
).fetchall()
|
||||
|
||||
stage_data = []
|
||||
total_calls = 0
|
||||
total_tokens = 0
|
||||
total_duration = 0
|
||||
total_cache_read = 0
|
||||
total_cache_write = 0
|
||||
api_calls = 0
|
||||
sub_calls = 0
|
||||
api_spend = 0.0
|
||||
sub_estimate = 0.0
|
||||
sub_input_tokens = 0
|
||||
|
||||
for r in rows:
|
||||
calls = r["calls"] or 0
|
||||
inp = r["input_tokens"] or 0
|
||||
out = r["output_tokens"] or 0
|
||||
dur = r["duration_ms"] or 0
|
||||
cr = r["cache_read_tokens"] or 0
|
||||
cw = r["cache_write_tokens"] or 0
|
||||
cost = r["api_cost"] or 0
|
||||
est = r["cost_estimate_usd"] or 0
|
||||
stage_name = r["stage"]
|
||||
billing = "subscription" if ":max" in stage_name else "api"
|
||||
|
||||
total_calls += calls
|
||||
total_tokens += inp + out
|
||||
total_duration += dur
|
||||
total_cache_read += cr
|
||||
total_cache_write += cw
|
||||
|
||||
if billing == "subscription":
|
||||
sub_calls += calls
|
||||
sub_estimate += est
|
||||
sub_input_tokens += inp
|
||||
else:
|
||||
api_calls += calls
|
||||
api_spend += cost
|
||||
|
||||
stage_data.append({
|
||||
"stage": stage_name,
|
||||
"model": r["model"],
|
||||
"calls": calls,
|
||||
"input_tokens": inp,
|
||||
"output_tokens": out,
|
||||
"total_tokens": inp + out,
|
||||
"duration_ms": dur,
|
||||
"avg_latency_ms": round(dur / calls) if calls else 0,
|
||||
"cache_read_tokens": cr,
|
||||
"cache_write_tokens": cw,
|
||||
"cache_hit_rate": round(cr / (cr + inp), 3) if (cr + inp) else 0,
|
||||
"api_cost": round(cost, 4),
|
||||
"cost_estimate_usd": round(est, 4),
|
||||
"billing": billing,
|
||||
})
|
||||
|
||||
# Cache summary (only meaningful for subscription/Max calls)
|
||||
total_cacheable = total_cache_read + total_cache_write + sub_input_tokens
|
||||
cache_hit_rate = round(total_cache_read / total_cacheable, 3) if total_cacheable else 0
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"by_stage": stage_data,
|
||||
"cache": {
|
||||
"read_tokens": total_cache_read,
|
||||
"write_tokens": total_cache_write,
|
||||
"hit_rate": cache_hit_rate,
|
||||
"note": "Cache hits are prompt tokens served from cache (cheaper/faster)",
|
||||
},
|
||||
"latency": {
|
||||
"total_ms": total_duration,
|
||||
"avg_ms_per_call": round(total_duration / total_calls) if total_calls else 0,
|
||||
"note": "Wall-clock time including network. Only populated for Claude Max calls.",
|
||||
},
|
||||
"subscription_estimate": {
|
||||
"total_cost_usd": round(sub_estimate, 4),
|
||||
"note": "What subscription calls would cost at API rates. Actual cost: $0 (flat-rate Max plan).",
|
||||
},
|
||||
"system": {
|
||||
"total_calls": total_calls,
|
||||
"total_tokens": total_tokens,
|
||||
"api_calls": api_calls,
|
||||
"subscription_calls": sub_calls,
|
||||
"api_spend": round(api_spend, 4),
|
||||
"subscription_estimate": round(sub_estimate, 4),
|
||||
"cache_hit_rate": cache_hit_rate,
|
||||
},
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"""Tier 1 Metrics — API routes for Argus dashboard.
|
||||
|
||||
Four endpoints:
|
||||
GET /api/yield — extraction yield per agent per day
|
||||
GET /api/cost-per-claim — cost per merged claim per day + stage breakdown
|
||||
GET /api/fix-rates — fix success rate by rejection tag
|
||||
GET /api/compute-profile — full compute telemetry (cache, latency, cost estimates)
|
||||
|
||||
All accept ?days=N (default 30) to control lookback window.
|
||||
|
||||
Owner: Argus <69AF7290-758F-464B-B472-04AFCA4AB340>
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from tier1_metrics import cost_per_merged_claim, compute_profile, extraction_yield, fix_success_by_tag
|
||||
|
||||
|
||||
def _parse_days(request, default=30):
|
||||
"""Parse and clamp ?days= parameter. Returns 1..365."""
|
||||
try:
|
||||
days = int(request.query.get("days", str(default)))
|
||||
except (ValueError, TypeError):
|
||||
days = default
|
||||
return max(1, min(days, 365))
|
||||
|
||||
|
||||
async def handle_yield(request):
|
||||
conn = request.app["_get_conn"]()
|
||||
days = _parse_days(request)
|
||||
return web.json_response(extraction_yield(conn, days))
|
||||
|
||||
|
||||
async def handle_cost_per_claim(request):
|
||||
conn = request.app["_get_conn"]()
|
||||
days = _parse_days(request)
|
||||
return web.json_response(cost_per_merged_claim(conn, days))
|
||||
|
||||
|
||||
async def handle_fix_rates(request):
|
||||
conn = request.app["_get_conn"]()
|
||||
days = _parse_days(request)
|
||||
return web.json_response(fix_success_by_tag(conn, days))
|
||||
|
||||
|
||||
async def handle_compute_profile(request):
|
||||
conn = request.app["_get_conn"]()
|
||||
days = _parse_days(request)
|
||||
return web.json_response(compute_profile(conn, days))
|
||||
|
||||
|
||||
def register_tier1_routes(app: web.Application, get_conn):
|
||||
app["_get_conn"] = get_conn
|
||||
app.router.add_get("/api/yield", handle_yield)
|
||||
app.router.add_get("/api/cost-per-claim", handle_cost_per_claim)
|
||||
app.router.add_get("/api/fix-rates", handle_fix_rates)
|
||||
app.router.add_get("/api/compute-profile", handle_compute_profile)
|
||||
|
|
@ -1,629 +0,0 @@
|
|||
"""Agent Vitality Diagnostics — data collection and schema.
|
||||
|
||||
Records daily vitality snapshots per agent across 10 dimensions.
|
||||
Designed as the objective function for agent "aliveness" ranking.
|
||||
|
||||
Owner: Ship (data collection) + Argus (storage, API, dashboard)
|
||||
Data sources: pipeline.db (read-only), claim-index API, agent-state filesystem, review_records
|
||||
|
||||
Dimension keys (agreed with Leo 2026-04-08):
|
||||
knowledge_output, knowledge_quality, contributor_engagement,
|
||||
review_performance, spend_efficiency, autonomy,
|
||||
infrastructure_health, social_reach, capital, external_impact
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("vitality")
|
||||
|
||||
# Known domain agents and their primary domains
|
||||
AGENT_DOMAINS = {
|
||||
"rio": ["internet-finance"],
|
||||
"theseus": ["collective-intelligence", "living-agents"],
|
||||
"astra": ["space-development", "energy", "manufacturing", "robotics"],
|
||||
"vida": ["health"],
|
||||
"clay": ["entertainment", "cultural-dynamics"],
|
||||
"leo": ["grand-strategy", "teleohumanity"],
|
||||
"hermes": [], # communications, no domain
|
||||
"rhea": [], # infrastructure ops, no domain
|
||||
"ganymede": [], # code review, no domain
|
||||
"epimetheus": [], # pipeline, no domain
|
||||
"oberon": [], # dashboard, no domain
|
||||
"argus": [], # diagnostics, no domain
|
||||
"ship": [], # engineering, no domain
|
||||
}
|
||||
|
||||
# Agent file path prefixes — for matching claims by location, not just domain field.
|
||||
# Handles claims in core/ and foundations/ that may not have a standard domain field
|
||||
# in the claim-index (domain derived from directory path).
|
||||
AGENT_PATHS = {
|
||||
"rio": ["domains/internet-finance/"],
|
||||
"theseus": ["domains/ai-alignment/", "core/living-agents/", "core/collective-intelligence/",
|
||||
"foundations/collective-intelligence/"],
|
||||
"astra": ["domains/space-development/", "domains/energy/",
|
||||
"domains/manufacturing/", "domains/robotics/"],
|
||||
"vida": ["domains/health/"],
|
||||
"clay": ["domains/entertainment/", "foundations/cultural-dynamics/"],
|
||||
"leo": ["core/grand-strategy/", "core/teleohumanity/", "core/mechanisms/",
|
||||
"core/living-capital/", "foundations/teleological-economics/",
|
||||
"foundations/critical-systems/"],
|
||||
}
|
||||
|
||||
ALL_AGENTS = list(AGENT_DOMAINS.keys())
|
||||
|
||||
# Agent-state directory (VPS filesystem)
|
||||
AGENT_STATE_DIR = Path(os.environ.get(
|
||||
"AGENT_STATE_DIR", "/opt/teleo-eval/agent-state"
|
||||
))
|
||||
|
||||
MIGRATION_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS vitality_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL,
|
||||
dimension TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL DEFAULT 0,
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
source TEXT,
|
||||
recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(agent_name, dimension, metric, recorded_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vitality_agent_time
|
||||
ON vitality_snapshots(agent_name, recorded_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vitality_dimension
|
||||
ON vitality_snapshots(dimension, recorded_at);
|
||||
"""
|
||||
|
||||
# Add source column if missing (idempotent upgrade from v1 schema)
|
||||
UPGRADE_SQL = """
|
||||
ALTER TABLE vitality_snapshots ADD COLUMN source TEXT;
|
||||
"""
|
||||
|
||||
|
||||
def ensure_schema(db_path: str):
|
||||
"""Create vitality_snapshots table if it doesn't exist."""
|
||||
conn = sqlite3.connect(db_path, timeout=30)
|
||||
try:
|
||||
conn.executescript(MIGRATION_SQL)
|
||||
try:
|
||||
conn.execute(UPGRADE_SQL)
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
logger.info("vitality_snapshots schema ensured")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _fetch_claim_index(url: str = "http://localhost:8080/claim-index") -> dict | None:
|
||||
"""Fetch claim-index from pipeline health API."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
logger.warning("claim-index fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _ro_conn(db_path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 1: knowledge_output — "How much has this agent produced?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_knowledge_output(conn: sqlite3.Connection, agent: str) -> list[dict]:
|
||||
"""Claims merged, domain count, PRs submitted."""
|
||||
metrics = []
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM prs WHERE agent = ? AND status = 'merged'",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "claims_merged", "value": row["cnt"], "unit": "claims"})
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(DISTINCT domain) as cnt FROM prs "
|
||||
"WHERE agent = ? AND domain IS NOT NULL AND status = 'merged'",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "domains_contributed", "value": row["cnt"], "unit": "domains"})
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM prs WHERE agent = ? AND created_at > datetime('now', '-7 days')",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "prs_7d", "value": row["cnt"], "unit": "PRs"})
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 2: knowledge_quality — "How good is the output?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_knowledge_quality(
|
||||
conn: sqlite3.Connection, claim_index: dict | None, agent: str
|
||||
) -> list[dict]:
|
||||
"""Evidence density, challenge rate, cross-domain links, domain coverage."""
|
||||
metrics = []
|
||||
agent_domains = AGENT_DOMAINS.get(agent, [])
|
||||
|
||||
# Challenge rate = challenge PRs / total PRs
|
||||
rows = conn.execute(
|
||||
"SELECT commit_type, COUNT(*) as cnt FROM prs "
|
||||
"WHERE agent = ? AND commit_type IS NOT NULL GROUP BY commit_type",
|
||||
(agent,),
|
||||
).fetchall()
|
||||
total = sum(r["cnt"] for r in rows)
|
||||
type_counts = {r["commit_type"]: r["cnt"] for r in rows}
|
||||
challenge_rate = type_counts.get("challenge", 0) / total if total > 0 else 0
|
||||
metrics.append({"metric": "challenge_rate", "value": round(challenge_rate, 4), "unit": "ratio"})
|
||||
|
||||
# Activity breadth (distinct commit types)
|
||||
metrics.append({"metric": "activity_breadth", "value": len(type_counts), "unit": "types"})
|
||||
|
||||
# Evidence density + cross-domain links from claim-index
|
||||
# Match by domain field OR file path prefix (catches core/, foundations/ claims)
|
||||
agent_paths = AGENT_PATHS.get(agent, [])
|
||||
if claim_index and (agent_domains or agent_paths):
|
||||
claims = claim_index.get("claims", [])
|
||||
agent_claims = [
|
||||
c for c in claims
|
||||
if c.get("domain") in agent_domains
|
||||
or any(c.get("file", "").startswith(p) for p in agent_paths)
|
||||
]
|
||||
total_claims = len(agent_claims)
|
||||
|
||||
# Evidence density: claims with incoming links / total claims
|
||||
linked = sum(1 for c in agent_claims if c.get("incoming_count", 0) > 0)
|
||||
density = linked / total_claims if total_claims > 0 else 0
|
||||
metrics.append({"metric": "evidence_density", "value": round(density, 4), "unit": "ratio"})
|
||||
|
||||
# Cross-domain links
|
||||
cross_domain = sum(
|
||||
1 for c in agent_claims
|
||||
for link in c.get("outgoing_links", [])
|
||||
if any(d in link for d in claim_index.get("domains", {}).keys()
|
||||
if d not in agent_domains)
|
||||
)
|
||||
metrics.append({"metric": "cross_domain_links", "value": cross_domain, "unit": "links"})
|
||||
|
||||
# Domain coverage: agent's claims / average domain size
|
||||
domains_data = claim_index.get("domains", {})
|
||||
agent_claim_count = sum(domains_data.get(d, 0) for d in agent_domains)
|
||||
avg_domain_size = (sum(domains_data.values()) / len(domains_data)) if domains_data else 1
|
||||
coverage = min(agent_claim_count / avg_domain_size, 1.0) if avg_domain_size > 0 else 0
|
||||
metrics.append({"metric": "domain_coverage", "value": round(coverage, 4), "unit": "ratio"})
|
||||
else:
|
||||
metrics.append({"metric": "evidence_density", "value": 0, "unit": "ratio"})
|
||||
metrics.append({"metric": "cross_domain_links", "value": 0, "unit": "links"})
|
||||
metrics.append({"metric": "domain_coverage", "value": 0, "unit": "ratio"})
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 3: contributor_engagement — "Who contributes to this agent's domain?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_contributor_engagement(conn: sqlite3.Connection, agent: str) -> list[dict]:
|
||||
"""Unique submitters to this agent's domain."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(DISTINCT submitted_by) as cnt FROM prs "
|
||||
"WHERE agent = ? AND submitted_by IS NOT NULL AND submitted_by != ''",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
return [
|
||||
{"metric": "unique_submitters", "value": row["cnt"], "unit": "contributors"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 4: review_performance — "How good is the evaluator feedback loop?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_review_performance(conn: sqlite3.Connection, agent: str) -> list[dict]:
|
||||
"""Approval rate, rejection reasons from review_records."""
|
||||
metrics = []
|
||||
|
||||
# Check if review_records table exists
|
||||
table_check = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='review_records'"
|
||||
).fetchone()
|
||||
if not table_check:
|
||||
return [
|
||||
{"metric": "approval_rate", "value": 0, "unit": "ratio"},
|
||||
{"metric": "total_reviews", "value": 0, "unit": "reviews"},
|
||||
]
|
||||
|
||||
# Overall approval rate for this agent's claims (join through prs table)
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as total, "
|
||||
"SUM(CASE WHEN r.outcome = 'approved' THEN 1 ELSE 0 END) as approved, "
|
||||
"SUM(CASE WHEN r.outcome = 'approved-with-changes' THEN 1 ELSE 0 END) as with_changes, "
|
||||
"SUM(CASE WHEN r.outcome = 'rejected' THEN 1 ELSE 0 END) as rejected "
|
||||
"FROM review_records r "
|
||||
"JOIN prs p ON r.pr_number = p.pr_number "
|
||||
"WHERE LOWER(p.agent) = LOWER(?)",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
total = row["total"] or 0
|
||||
approved = (row["approved"] or 0) + (row["with_changes"] or 0)
|
||||
rejected = row["rejected"] or 0
|
||||
approval_rate = approved / total if total > 0 else 0
|
||||
|
||||
metrics.append({"metric": "total_reviews", "value": total, "unit": "reviews"})
|
||||
metrics.append({"metric": "approval_rate", "value": round(approval_rate, 4), "unit": "ratio"})
|
||||
metrics.append({"metric": "approved", "value": row["approved"] or 0, "unit": "reviews"})
|
||||
metrics.append({"metric": "approved_with_changes", "value": row["with_changes"] or 0, "unit": "reviews"})
|
||||
metrics.append({"metric": "rejected", "value": rejected, "unit": "reviews"})
|
||||
|
||||
# Top rejection reasons (last 30 days)
|
||||
reasons = conn.execute(
|
||||
"SELECT r.rejection_reason, COUNT(*) as cnt FROM review_records r "
|
||||
"JOIN prs p ON r.pr_number = p.pr_number "
|
||||
"WHERE LOWER(p.agent) = LOWER(?) AND r.outcome = 'rejected' "
|
||||
"AND r.rejection_reason IS NOT NULL "
|
||||
"AND r.review_date > datetime('now', '-30 days') "
|
||||
"GROUP BY r.rejection_reason ORDER BY cnt DESC",
|
||||
(agent,),
|
||||
).fetchall()
|
||||
for r in reasons:
|
||||
metrics.append({
|
||||
"metric": f"rejection_{r['rejection_reason']}",
|
||||
"value": r["cnt"],
|
||||
"unit": "rejections",
|
||||
})
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 5: spend_efficiency — "What does it cost per merged claim?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_spend_efficiency(conn: sqlite3.Connection, agent: str) -> list[dict]:
|
||||
"""Cost per merged claim, total spend, response costs."""
|
||||
metrics = []
|
||||
|
||||
# Pipeline cost attributed to this agent (from prs.cost_usd)
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(cost_usd), 0) as cost, COUNT(*) as merged "
|
||||
"FROM prs WHERE agent = ? AND status = 'merged'",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
total_cost = row["cost"] or 0
|
||||
merged = row["merged"] or 0
|
||||
cost_per_claim = total_cost / merged if merged > 0 else 0
|
||||
|
||||
metrics.append({"metric": "total_pipeline_cost", "value": round(total_cost, 4), "unit": "USD"})
|
||||
metrics.append({"metric": "cost_per_merged_claim", "value": round(cost_per_claim, 4), "unit": "USD"})
|
||||
|
||||
# Response audit costs (Telegram bot) — per-agent
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(generation_cost), 0) as cost, COUNT(*) as cnt "
|
||||
"FROM response_audit WHERE agent = ?",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "response_cost_total", "value": round(row["cost"], 4), "unit": "USD"})
|
||||
metrics.append({"metric": "total_responses", "value": row["cnt"], "unit": "responses"})
|
||||
|
||||
# 24h spend snapshot
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(generation_cost), 0) as cost "
|
||||
"FROM response_audit WHERE agent = ? AND timestamp > datetime('now', '-24 hours')",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "response_cost_24h", "value": round(row["cost"], 4), "unit": "USD"})
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 6: autonomy — "How independently does this agent act?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_autonomy(conn: sqlite3.Connection, agent: str) -> list[dict]:
|
||||
"""Self-directed actions, active days."""
|
||||
metrics = []
|
||||
|
||||
# Autonomous responses in last 24h
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM response_audit "
|
||||
"WHERE agent = ? AND timestamp > datetime('now', '-24 hours')",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "autonomous_responses_24h", "value": row["cnt"], "unit": "actions"})
|
||||
|
||||
# Active days in last 7
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(DISTINCT date(created_at)) as days FROM prs "
|
||||
"WHERE agent = ? AND created_at > datetime('now', '-7 days')",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
metrics.append({"metric": "active_days_7d", "value": row["days"], "unit": "days"})
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension 7: infrastructure_health — "Is the agent's machinery working?"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_infrastructure_health(conn: sqlite3.Connection, agent: str) -> list[dict]:
|
||||
"""Circuit breakers, PR success rate, agent-state liveness."""
|
||||
metrics = []
|
||||
|
||||
# Circuit breakers
|
||||
rows = conn.execute(
|
||||
"SELECT name, state FROM circuit_breakers WHERE name LIKE ?",
|
||||
(f"%{agent}%",),
|
||||
).fetchall()
|
||||
open_breakers = sum(1 for r in rows if r["state"] != "closed")
|
||||
metrics.append({"metric": "open_circuit_breakers", "value": open_breakers, "unit": "breakers"})
|
||||
|
||||
# PR success rate last 7 days
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as total, "
|
||||
"SUM(CASE WHEN status='merged' THEN 1 ELSE 0 END) as merged "
|
||||
"FROM prs WHERE agent = ? AND created_at > datetime('now', '-7 days')",
|
||||
(agent,),
|
||||
).fetchone()
|
||||
total = row["total"]
|
||||
rate = row["merged"] / total if total > 0 else 0
|
||||
metrics.append({"metric": "merge_rate_7d", "value": round(rate, 4), "unit": "ratio"})
|
||||
|
||||
# Agent-state liveness (read metrics.json from filesystem)
|
||||
state_file = AGENT_STATE_DIR / agent / "metrics.json"
|
||||
if state_file.exists():
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
state = json.load(f)
|
||||
lifetime = state.get("lifetime", {})
|
||||
metrics.append({
|
||||
"metric": "sessions_total",
|
||||
"value": lifetime.get("sessions_total", 0),
|
||||
"unit": "sessions",
|
||||
})
|
||||
metrics.append({
|
||||
"metric": "sessions_timeout",
|
||||
"value": lifetime.get("sessions_timeout", 0),
|
||||
"unit": "sessions",
|
||||
})
|
||||
metrics.append({
|
||||
"metric": "sessions_error",
|
||||
"value": lifetime.get("sessions_error", 0),
|
||||
"unit": "sessions",
|
||||
})
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to read agent-state for %s: %s", agent, e)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimensions 8-10: Stubs (no data sources yet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_social_reach(agent: str) -> list[dict]:
|
||||
"""Social dimension: stub zeros until X API accounts are active."""
|
||||
return [
|
||||
{"metric": "followers", "value": 0, "unit": "followers"},
|
||||
{"metric": "impressions_7d", "value": 0, "unit": "impressions"},
|
||||
{"metric": "engagement_rate", "value": 0, "unit": "ratio"},
|
||||
]
|
||||
|
||||
|
||||
def collect_capital(agent: str) -> list[dict]:
|
||||
"""Capital dimension: stub zeros until treasury/revenue tracking exists."""
|
||||
return [
|
||||
{"metric": "aum", "value": 0, "unit": "USD"},
|
||||
{"metric": "treasury", "value": 0, "unit": "USD"},
|
||||
]
|
||||
|
||||
|
||||
def collect_external_impact(agent: str) -> list[dict]:
|
||||
"""External impact dimension: stub zeros until manual tracking exists."""
|
||||
return [
|
||||
{"metric": "decisions_informed", "value": 0, "unit": "decisions"},
|
||||
{"metric": "deals_sourced", "value": 0, "unit": "deals"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DIMENSION_MAP = {
|
||||
"knowledge_output": lambda conn, ci, agent: collect_knowledge_output(conn, agent),
|
||||
"knowledge_quality": collect_knowledge_quality,
|
||||
"contributor_engagement": lambda conn, ci, agent: collect_contributor_engagement(conn, agent),
|
||||
"review_performance": lambda conn, ci, agent: collect_review_performance(conn, agent),
|
||||
"spend_efficiency": lambda conn, ci, agent: collect_spend_efficiency(conn, agent),
|
||||
"autonomy": lambda conn, ci, agent: collect_autonomy(conn, agent),
|
||||
"infrastructure_health": lambda conn, ci, agent: collect_infrastructure_health(conn, agent),
|
||||
"social_reach": lambda conn, ci, agent: collect_social_reach(agent),
|
||||
"capital": lambda conn, ci, agent: collect_capital(agent),
|
||||
"external_impact": lambda conn, ci, agent: collect_external_impact(agent),
|
||||
}
|
||||
|
||||
|
||||
def collect_all_for_agent(
|
||||
db_path: str,
|
||||
agent: str,
|
||||
claim_index_url: str = "http://localhost:8080/claim-index",
|
||||
) -> dict:
|
||||
"""Collect all 10 vitality dimensions for a single agent.
|
||||
Returns {dimension: [metrics]}.
|
||||
"""
|
||||
claim_index = _fetch_claim_index(claim_index_url)
|
||||
conn = _ro_conn(db_path)
|
||||
try:
|
||||
result = {}
|
||||
for dim_key, collector in DIMENSION_MAP.items():
|
||||
try:
|
||||
result[dim_key] = collector(conn, claim_index, agent)
|
||||
except Exception as e:
|
||||
logger.error("collector %s failed for %s: %s", dim_key, agent, e)
|
||||
result[dim_key] = []
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def collect_system_aggregate(
|
||||
db_path: str,
|
||||
claim_index_url: str = "http://localhost:8080/claim-index",
|
||||
) -> dict:
|
||||
"""System-level aggregate vitality metrics."""
|
||||
claim_index = _fetch_claim_index(claim_index_url)
|
||||
conn = _ro_conn(db_path)
|
||||
try:
|
||||
metrics = {}
|
||||
|
||||
# Knowledge totals
|
||||
total_claims = claim_index["total_claims"] if claim_index else 0
|
||||
orphan_ratio = claim_index.get("orphan_ratio", 0) if claim_index else 0
|
||||
domain_count = len(claim_index.get("domains", {})) if claim_index else 0
|
||||
|
||||
metrics["knowledge_output"] = [
|
||||
{"metric": "total_claims", "value": total_claims, "unit": "claims"},
|
||||
{"metric": "total_domains", "value": domain_count, "unit": "domains"},
|
||||
{"metric": "orphan_ratio", "value": round(orphan_ratio, 4), "unit": "ratio"},
|
||||
]
|
||||
|
||||
# Cross-domain citation rate
|
||||
if claim_index:
|
||||
claims = claim_index.get("claims", [])
|
||||
total_links = sum(c.get("outgoing_count", 0) for c in claims)
|
||||
cross_domain = 0
|
||||
for c in claims:
|
||||
src_domain = c.get("domain")
|
||||
for link in c.get("outgoing_links", []):
|
||||
linked_claims = [
|
||||
x for x in claims
|
||||
if x.get("stem") in link or x.get("file", "").endswith(link + ".md")
|
||||
]
|
||||
for lc in linked_claims:
|
||||
if lc.get("domain") != src_domain:
|
||||
cross_domain += 1
|
||||
metrics["knowledge_quality"] = [
|
||||
{"metric": "cross_domain_citation_rate",
|
||||
"value": round(cross_domain / max(total_links, 1), 4),
|
||||
"unit": "ratio"},
|
||||
]
|
||||
|
||||
# Pipeline throughput
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as merged FROM prs "
|
||||
"WHERE status='merged' AND merged_at > datetime('now', '-24 hours')"
|
||||
).fetchone()
|
||||
row2 = conn.execute("SELECT COUNT(*) as total FROM sources").fetchone()
|
||||
row3 = conn.execute(
|
||||
"SELECT COUNT(*) as pending FROM prs "
|
||||
"WHERE status NOT IN ('merged','rejected','closed')"
|
||||
).fetchone()
|
||||
|
||||
metrics["infrastructure_health"] = [
|
||||
{"metric": "prs_merged_24h", "value": row["merged"], "unit": "PRs/day"},
|
||||
{"metric": "total_sources", "value": row2["total"], "unit": "sources"},
|
||||
{"metric": "queue_depth", "value": row3["pending"], "unit": "PRs"},
|
||||
]
|
||||
|
||||
# Total spend
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(cost_usd), 0) as cost "
|
||||
"FROM costs WHERE date > date('now', '-1 day')"
|
||||
).fetchone()
|
||||
row2 = conn.execute(
|
||||
"SELECT COALESCE(SUM(generation_cost), 0) as cost FROM response_audit "
|
||||
"WHERE timestamp > datetime('now', '-24 hours')"
|
||||
).fetchone()
|
||||
metrics["spend_efficiency"] = [
|
||||
{"metric": "pipeline_cost_24h", "value": round(row["cost"], 4), "unit": "USD"},
|
||||
{"metric": "response_cost_24h", "value": round(row2["cost"], 4), "unit": "USD"},
|
||||
{"metric": "total_cost_24h",
|
||||
"value": round(row["cost"] + row2["cost"], 4), "unit": "USD"},
|
||||
]
|
||||
|
||||
# Stubs
|
||||
metrics["social_reach"] = [{"metric": "total_followers", "value": 0, "unit": "followers"}]
|
||||
metrics["capital"] = [{"metric": "total_aum", "value": 0, "unit": "USD"}]
|
||||
|
||||
return metrics
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def record_snapshot(
|
||||
db_path: str,
|
||||
claim_index_url: str = "http://localhost:8080/claim-index",
|
||||
):
|
||||
"""Run a full vitality snapshot — one row per agent per dimension per metric."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
rows = []
|
||||
|
||||
# Per-agent snapshots
|
||||
for agent in ALL_AGENTS:
|
||||
try:
|
||||
dimensions = collect_all_for_agent(db_path, agent, claim_index_url)
|
||||
for dim_name, metrics in dimensions.items():
|
||||
collector_name = f"{dim_name}_collector"
|
||||
for m in metrics:
|
||||
rows.append((
|
||||
agent, dim_name, m["metric"], m["value"],
|
||||
m["unit"], collector_name, now,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error("vitality collection failed for %s: %s", agent, e)
|
||||
|
||||
# System aggregate
|
||||
try:
|
||||
system = collect_system_aggregate(db_path, claim_index_url)
|
||||
for dim_name, metrics in system.items():
|
||||
for m in metrics:
|
||||
rows.append((
|
||||
"_system", dim_name, m["metric"], m["value"],
|
||||
m["unit"], "system_aggregate", now,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error("vitality system aggregate failed: %s", e)
|
||||
|
||||
# Write all rows
|
||||
ensure_schema(db_path)
|
||||
conn = sqlite3.connect(db_path, timeout=30)
|
||||
try:
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO vitality_snapshots "
|
||||
"(agent_name, dimension, metric, value, unit, source, recorded_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"vitality snapshot recorded: %d rows for %d agents + system",
|
||||
len(rows), len(ALL_AGENTS),
|
||||
)
|
||||
return {"rows_written": len(rows), "agents": len(ALL_AGENTS), "recorded_at": now}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""CLI: python3 vitality.py [db_path] — runs a snapshot."""
|
||||
import sys
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
db = sys.argv[1] if len(sys.argv) > 1 else "/opt/teleo-eval/pipeline/pipeline.db"
|
||||
result = record_snapshot(db)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
"""Vitality API routes for Argus diagnostics dashboard.
|
||||
|
||||
Endpoints:
|
||||
GET /api/vitality — latest snapshot + time-series for all agents or one
|
||||
GET /api/vitality/snapshot — trigger a new snapshot (POST-like via GET for cron curl)
|
||||
GET /api/vitality/leaderboard — agents ranked by composite vitality score
|
||||
|
||||
Owner: Argus
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from vitality import (
|
||||
ALL_AGENTS,
|
||||
MIGRATION_SQL,
|
||||
collect_all_for_agent,
|
||||
collect_system_aggregate,
|
||||
record_snapshot,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("argus.vitality")
|
||||
|
||||
# Composite vitality weights — Leo-approved 2026-04-08
|
||||
# Dimension keys match Ship's refactored vitality.py DIMENSION_MAP
|
||||
VITALITY_WEIGHTS = {
|
||||
"knowledge_output": 0.30, # primary output — highest weight
|
||||
"knowledge_quality": 0.20, # was "diversity" — quality of output
|
||||
"contributor_engagement": 0.15, # attracting external contributors
|
||||
"review_performance": 0.00, # new dim, zero until review_records populated
|
||||
"autonomy": 0.15, # independent action
|
||||
"infrastructure_health": 0.05, # machinery working
|
||||
"spend_efficiency": 0.05, # cost discipline
|
||||
"social_reach": 0.00, # zero until accounts active
|
||||
"capital": 0.00, # zero until treasury exists
|
||||
"external_impact": 0.00, # zero until measurable
|
||||
}
|
||||
|
||||
# Public paths (no auth required)
|
||||
VITALITY_PUBLIC_PATHS = frozenset({
|
||||
"/api/vitality",
|
||||
"/api/vitality/snapshot",
|
||||
"/api/vitality/leaderboard",
|
||||
})
|
||||
|
||||
|
||||
def _ro_conn(db_path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
async def handle_vitality(request: web.Request) -> web.Response:
|
||||
"""GET /api/vitality?agent=<name>&days=7
|
||||
|
||||
Returns latest snapshot and time-series data.
|
||||
If agent is specified, returns that agent only. Otherwise returns all.
|
||||
"""
|
||||
db_path = request.app["db_path"]
|
||||
agent = request.query.get("agent")
|
||||
try:
|
||||
days = min(int(request.query.get("days", "7")), 90)
|
||||
except ValueError:
|
||||
days = 7
|
||||
|
||||
conn = _ro_conn(db_path)
|
||||
try:
|
||||
# Check if table exists
|
||||
table_check = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='vitality_snapshots'"
|
||||
).fetchone()
|
||||
if not table_check:
|
||||
return web.json_response({
|
||||
"error": "No vitality data yet. Trigger a snapshot first via /api/vitality/snapshot",
|
||||
"has_data": False
|
||||
})
|
||||
|
||||
# Latest snapshot timestamp
|
||||
latest = conn.execute(
|
||||
"SELECT MAX(recorded_at) as ts FROM vitality_snapshots"
|
||||
).fetchone()
|
||||
latest_ts = latest["ts"] if latest else None
|
||||
|
||||
if not latest_ts:
|
||||
return web.json_response({"has_data": False})
|
||||
|
||||
# Latest snapshot data
|
||||
if agent:
|
||||
agents_filter = [agent]
|
||||
else:
|
||||
agents_filter = ALL_AGENTS + ["_system"]
|
||||
|
||||
result = {"latest_snapshot": latest_ts, "agents": {}}
|
||||
|
||||
for a in agents_filter:
|
||||
rows = conn.execute(
|
||||
"SELECT dimension, metric, value, unit FROM vitality_snapshots "
|
||||
"WHERE agent_name = ? AND recorded_at = ?",
|
||||
(a, latest_ts)
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
dimensions = {}
|
||||
for r in rows:
|
||||
dim = r["dimension"]
|
||||
if dim not in dimensions:
|
||||
dimensions[dim] = []
|
||||
dimensions[dim].append({
|
||||
"metric": r["metric"],
|
||||
"value": r["value"],
|
||||
"unit": r["unit"],
|
||||
})
|
||||
result["agents"][a] = dimensions
|
||||
|
||||
# Time-series for trend charts (one data point per snapshot)
|
||||
ts_query_agent = agent if agent else "_system"
|
||||
ts_rows = conn.execute(
|
||||
"SELECT recorded_at, dimension, metric, value "
|
||||
"FROM vitality_snapshots "
|
||||
"WHERE agent_name = ? AND recorded_at > datetime('now', ?)"
|
||||
"ORDER BY recorded_at",
|
||||
(ts_query_agent, f"-{days} days")
|
||||
).fetchall()
|
||||
|
||||
time_series = {}
|
||||
for r in ts_rows:
|
||||
key = f"{r['dimension']}.{r['metric']}"
|
||||
if key not in time_series:
|
||||
time_series[key] = []
|
||||
time_series[key].append({
|
||||
"t": r["recorded_at"],
|
||||
"v": r["value"],
|
||||
})
|
||||
result["time_series"] = time_series
|
||||
result["has_data"] = True
|
||||
|
||||
return web.json_response(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def handle_vitality_snapshot(request: web.Request) -> web.Response:
|
||||
"""GET /api/vitality/snapshot — trigger a new snapshot collection.
|
||||
|
||||
Used by cron: curl http://localhost:8081/api/vitality/snapshot
|
||||
Requires ?confirm=1 to prevent accidental triggers from crawlers/prefetch.
|
||||
"""
|
||||
if request.query.get("confirm") != "1":
|
||||
return web.json_response(
|
||||
{"status": "noop", "error": "Add ?confirm=1 to trigger a snapshot write"},
|
||||
status=400,
|
||||
)
|
||||
db_path = request.app["db_path"]
|
||||
claim_index_url = request.app.get("claim_index_url", "http://localhost:8080/claim-index")
|
||||
|
||||
try:
|
||||
result = record_snapshot(db_path, claim_index_url)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("vitality snapshot failed: %s", e)
|
||||
return web.json_response({"status": "error", "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_vitality_leaderboard(request: web.Request) -> web.Response:
|
||||
"""GET /api/vitality/leaderboard — agents ranked by composite vitality score.
|
||||
|
||||
Scoring approach:
|
||||
- Each dimension gets a 0-1 normalized score based on the metric values
|
||||
- Weighted sum produces composite score
|
||||
- Agents ranked by composite score descending
|
||||
"""
|
||||
db_path = request.app["db_path"]
|
||||
conn = _ro_conn(db_path)
|
||||
try:
|
||||
table_check = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='vitality_snapshots'"
|
||||
).fetchone()
|
||||
if not table_check:
|
||||
return web.json_response({"error": "No vitality data yet", "has_data": False})
|
||||
|
||||
latest = conn.execute(
|
||||
"SELECT MAX(recorded_at) as ts FROM vitality_snapshots"
|
||||
).fetchone()
|
||||
if not latest or not latest["ts"]:
|
||||
return web.json_response({"has_data": False})
|
||||
|
||||
latest_ts = latest["ts"]
|
||||
|
||||
# Collect all agents' latest data
|
||||
agent_scores = []
|
||||
for agent in ALL_AGENTS:
|
||||
rows = conn.execute(
|
||||
"SELECT dimension, metric, value FROM vitality_snapshots "
|
||||
"WHERE agent_name = ? AND recorded_at = ?",
|
||||
(agent, latest_ts)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
dims = {}
|
||||
for r in rows:
|
||||
dim = r["dimension"]
|
||||
if dim not in dims:
|
||||
dims[dim] = {}
|
||||
dims[dim][r["metric"]] = r["value"]
|
||||
|
||||
# Normalize each dimension to 0-1
|
||||
# Dimension keys match Ship's refactored vitality.py DIMENSION_MAP
|
||||
dim_scores = {}
|
||||
|
||||
# knowledge_output: claims_merged (cap at 100 = 1.0)
|
||||
ko = dims.get("knowledge_output", {})
|
||||
claims = ko.get("claims_merged", 0)
|
||||
dim_scores["knowledge_output"] = min(claims / 100, 1.0)
|
||||
|
||||
# knowledge_quality: challenge_rate + breadth + evidence_density + domain_coverage
|
||||
kq = dims.get("knowledge_quality", {})
|
||||
cr = kq.get("challenge_rate", 0)
|
||||
breadth = kq.get("activity_breadth", 0)
|
||||
evidence = kq.get("evidence_density", 0)
|
||||
coverage = kq.get("domain_coverage", 0)
|
||||
dim_scores["knowledge_quality"] = min(
|
||||
(cr / 0.1 * 0.2 + breadth / 4 * 0.2 + evidence * 0.3 + coverage * 0.3), 1.0
|
||||
)
|
||||
|
||||
# contributor_engagement: unique_submitters (cap at 5 = 1.0)
|
||||
ce = dims.get("contributor_engagement", {})
|
||||
dim_scores["contributor_engagement"] = min(ce.get("unique_submitters", 0) / 5, 1.0)
|
||||
|
||||
# review_performance: approval_rate from review_records (0 until populated)
|
||||
rp = dims.get("review_performance", {})
|
||||
dim_scores["review_performance"] = rp.get("approval_rate", 0)
|
||||
|
||||
# autonomy: active_days_7d (7 = 1.0)
|
||||
am = dims.get("autonomy", {})
|
||||
dim_scores["autonomy"] = min(am.get("active_days_7d", 0) / 7, 1.0)
|
||||
|
||||
# infrastructure_health: merge_rate_7d directly (already 0-1)
|
||||
ih = dims.get("infrastructure_health", {})
|
||||
dim_scores["infrastructure_health"] = ih.get("merge_rate_7d", 0)
|
||||
|
||||
# spend_efficiency: inverted — lower cost per claim is better
|
||||
se = dims.get("spend_efficiency", {})
|
||||
daily_cost = se.get("response_cost_24h", 0)
|
||||
dim_scores["spend_efficiency"] = max(1.0 - daily_cost / 10.0, 0)
|
||||
|
||||
# Social/Capital/External: stubbed at 0
|
||||
dim_scores["social_reach"] = 0
|
||||
dim_scores["capital"] = 0
|
||||
dim_scores["external_impact"] = 0
|
||||
|
||||
# Composite weighted score
|
||||
composite = sum(
|
||||
dim_scores.get(dim, 0) * weight
|
||||
for dim, weight in VITALITY_WEIGHTS.items()
|
||||
)
|
||||
|
||||
agent_scores.append({
|
||||
"agent": agent,
|
||||
"composite_score": round(composite, 4),
|
||||
"dimension_scores": {k: round(v, 4) for k, v in dim_scores.items()},
|
||||
"raw_highlights": {
|
||||
"claims_merged": int(claims),
|
||||
"merge_rate": round(ih.get("merge_rate_7d", 0) * 100, 1),
|
||||
"active_days": int(am.get("active_days_7d", 0)),
|
||||
"challenge_rate": round(cr * 100, 1),
|
||||
"evidence_density": round(evidence * 100, 1),
|
||||
},
|
||||
})
|
||||
|
||||
# Sort by composite score descending
|
||||
agent_scores.sort(key=lambda x: x["composite_score"], reverse=True)
|
||||
|
||||
return web.json_response({
|
||||
"has_data": True,
|
||||
"snapshot_at": latest_ts,
|
||||
"leaderboard": agent_scores,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_vitality_routes(app: web.Application):
|
||||
"""Register vitality endpoints on the aiohttp app."""
|
||||
app.router.add_get("/api/vitality", handle_vitality)
|
||||
app.router.add_get("/api/vitality/snapshot", handle_vitality_snapshot)
|
||||
app.router.add_get("/api/vitality/leaderboard", handle_vitality_leaderboard)
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /app
|
||||
|
||||
python -m compileall -q lib telegram diagnostics teleo-pipeline.py
|
||||
|
||||
python -m pytest -q \
|
||||
tests/test_agent_routing.py \
|
||||
tests/test_decision_engine_replay.py \
|
||||
tests/test_phase1b_end_to_end.py \
|
||||
tests/test_research_eval_schema_sql.py \
|
||||
tests/test_teleo_agent_systemd.py
|
||||
|
||||
python - <<'PY'
|
||||
import json
|
||||
from lib.agent_routing import classify_pr_route
|
||||
|
||||
route = classify_pr_route(
|
||||
"diff --git a/domains/internet-finance/x402-wallets.md b/domains/internet-finance/x402-wallets.md\n"
|
||||
"+AI systems route agents around x402 payments and agent wallets.\n"
|
||||
)
|
||||
|
||||
print(json.dumps({
|
||||
"smoke": "teleo-gcp-staging-ok",
|
||||
"primary_agent": route.primary_agent,
|
||||
"required_agents": list(route.required_agents),
|
||||
"route_kind": route.route_kind,
|
||||
}, sort_keys=True))
|
||||
PY
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
FROM ghcr.io/astral-sh/uv:0.9.30@sha256:538e0b39736e7feae937a65983e49d2ab75e1559d35041f9878b7b7e51de91e4 AS uv
|
||||
|
||||
FROM docker.io/library/python:3.11.9-slim-bookworm@sha256:8fb099199b9f2d70342674bd9dbccd3ed03a258f26bbd1d556822c6dfc60c317 AS python-build
|
||||
|
||||
COPY --from=uv /uv /usr/local/bin/uv
|
||||
COPY artifact/hermes-agent /opt/livingip/leoclean-nosend/artifact/hermes-agent
|
||||
|
||||
RUN /usr/local/bin/uv venv \
|
||||
--python /usr/local/bin/python3.11 \
|
||||
--no-project \
|
||||
/opt/livingip/leoclean-nosend/venv \
|
||||
&& VIRTUAL_ENV=/opt/livingip/leoclean-nosend/venv /usr/local/bin/uv sync \
|
||||
--directory /opt/livingip/leoclean-nosend/artifact/hermes-agent \
|
||||
--active \
|
||||
--frozen \
|
||||
--no-dev \
|
||||
--extra cron \
|
||||
--no-editable \
|
||||
&& /usr/local/bin/uv pip check --python /opt/livingip/leoclean-nosend/venv/bin/python
|
||||
|
||||
FROM docker.io/library/postgres:16.14-bookworm@sha256:92620daddcd947f8d5ab5ba66e848702fe443d87fed30c4cea8e389fd78dfc55
|
||||
|
||||
ARG TELEO_REVISION
|
||||
ARG ARTIFACT_SHA256
|
||||
ARG IDENTITY_SHA256
|
||||
ARG INPUT_SHA256
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/living-ip/teleo-infrastructure" \
|
||||
org.opencontainers.image.revision="${TELEO_REVISION}" \
|
||||
livingip.artifact.sha256="${ARTIFACT_SHA256}" \
|
||||
livingip.identity.sha256="${IDENTITY_SHA256}" \
|
||||
livingip.input.sha256="${INPUT_SHA256}" \
|
||||
livingip.runtime="leoclean-nosend" \
|
||||
livingip.target="gcp-staging"
|
||||
|
||||
COPY --from=python-build /usr/local /usr/local
|
||||
COPY --from=python-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=python-build /opt/livingip/leoclean-nosend/venv /opt/livingip/leoclean-nosend/venv
|
||||
COPY artifact /opt/livingip/leoclean-nosend/artifact
|
||||
COPY identity /opt/livingip/leoclean-nosend/identity
|
||||
COPY identity-sources /opt/livingip/leoclean-nosend/identity-sources
|
||||
COPY scripts /opt/livingip/leoclean-nosend/scripts
|
||||
COPY entrypoint.py /opt/livingip/leoclean-nosend/entrypoint.py
|
||||
COPY package_contract.py /opt/livingip/leoclean-nosend/package_contract.py
|
||||
COPY runtime_permissions_verifier.py /opt/livingip/leoclean-nosend/runtime_permissions_verifier.py
|
||||
COPY artifact-receipt.json /opt/livingip/leoclean-nosend/metadata/artifact-receipt.json
|
||||
COPY image-input.json /opt/livingip/leoclean-nosend/metadata/image-input.json
|
||||
COPY Dockerfile /opt/livingip/leoclean-nosend/metadata/Dockerfile
|
||||
COPY cloudsql-server-ca.pem /usr/local/libexec/livingip/leoclean-kb/cloudsql-server-ca.pem
|
||||
|
||||
RUN ln -s /usr/local/bin/python3.11 /usr/bin/python3 \
|
||||
&& test -d /usr/lib/ssl \
|
||||
&& if [ ! -L /usr/lib/ssl/cert.pem ] && [ ! -e /usr/lib/ssl/cert.pem ]; then \
|
||||
ln -s /etc/ssl/certs/ca-certificates.crt /usr/lib/ssl/cert.pem; \
|
||||
fi \
|
||||
&& test "$(readlink -f /usr/lib/ssl/cert.pem)" = "/etc/ssl/certs/ca-certificates.crt" \
|
||||
&& mkdir -p /usr/local/libexec/livingip/leoclean-kb/gcloud-config \
|
||||
&& chmod 0755 /usr/local/libexec/livingip/leoclean-kb \
|
||||
&& chmod 0555 /usr/local/libexec/livingip/leoclean-kb/gcloud-config \
|
||||
&& chmod 0444 /usr/local/libexec/livingip/leoclean-kb/cloudsql-server-ca.pem \
|
||||
&& chmod 0555 /opt/livingip/leoclean-nosend/entrypoint.py \
|
||||
&& chmod 0444 /opt/livingip/leoclean-nosend/package_contract.py \
|
||||
&& chmod 0444 /opt/livingip/leoclean-nosend/runtime_permissions_verifier.py \
|
||||
&& find /opt/livingip/leoclean-nosend/identity -type f -exec chmod 0444 {} + \
|
||||
&& case "$TELEO_REVISION" in (*[!0-9a-f]*|'') exit 65;; esac \
|
||||
&& test "${#TELEO_REVISION}" -eq 40 \
|
||||
&& for digest in "$ARTIFACT_SHA256" "$IDENTITY_SHA256" "$INPUT_SHA256"; do \
|
||||
case "$digest" in (*[!0-9a-f]*|'') exit 65;; esac; \
|
||||
test "${#digest}" -eq 64; \
|
||||
done \
|
||||
&& test "$(/usr/local/bin/python3.11 -c 'import platform; print(platform.python_version())')" = "3.11.9" \
|
||||
&& test "$(/usr/local/bin/python3.11 -c 'import ssl; print(ssl.get_default_verify_paths().cafile)')" = "/usr/lib/ssl/cert.pem" \
|
||||
&& test "$(/usr/local/bin/python3.11 -c 'import ssl; print(ssl.get_default_verify_paths().cafile)')" != "/usr/local/libexec/livingip/leoclean-kb/cloudsql-server-ca.pem" \
|
||||
&& /usr/local/bin/python3.11 -c 'import ssl; assert ssl.create_default_context().cert_store_stats().get("x509_ca", 0) > 0' \
|
||||
&& /usr/bin/psql --version | grep -Eq '^psql \(PostgreSQL\) 16[.]14([[:space:]]|$)' \
|
||||
&& test "$(sha256sum /usr/local/libexec/livingip/leoclean-kb/cloudsql-server-ca.pem | cut -d ' ' -f 1)" = "80701e768f0e1f6b9d621aa0b53f6e851daaa276c6d9a8e51a300fbc015539cb" \
|
||||
&& /opt/livingip/leoclean-nosend/venv/bin/python /opt/livingip/leoclean-nosend/entrypoint.py \
|
||||
verify-build "$TELEO_REVISION" "$ARTIFACT_SHA256" "$IDENTITY_SHA256" "$INPUT_SHA256"
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONNOUSERSITE=1 \
|
||||
PYTHONSAFEPATH=1
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=4 \
|
||||
CMD ["/opt/livingip/leoclean-nosend/venv/bin/python", "/opt/livingip/leoclean-nosend/entrypoint.py", "health"]
|
||||
|
||||
ENTRYPOINT ["/opt/livingip/leoclean-nosend/venv/bin/python", "/opt/livingip/leoclean-nosend/entrypoint.py"]
|
||||
CMD ["service"]
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and launch the immutable leoclean no-send container."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _load_adjacent_contract() -> Any:
|
||||
path = Path(__file__).resolve().with_name("package_contract.py")
|
||||
spec = importlib.util.spec_from_file_location("livingip_leoclean_nosend_package_contract", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("the adjacent package contract cannot be loaded")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_adjacent_runtime_permissions_verifier() -> Any:
|
||||
path = Path(__file__).resolve().with_name("runtime_permissions_verifier.py")
|
||||
spec = importlib.util.spec_from_file_location("livingip_leoclean_runtime_permissions_verifier", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("the adjacent runtime permissions verifier cannot be loaded")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except BaseException:
|
||||
sys.modules.pop(spec.name, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
|
||||
contract = _load_adjacent_contract()
|
||||
runtime_permissions_verifier = _load_adjacent_runtime_permissions_verifier()
|
||||
|
||||
ROOT = Path(contract.RUNTIME_ROOT)
|
||||
ARTIFACT = ROOT / "artifact"
|
||||
IDENTITY = ROOT / "identity"
|
||||
IDENTITY_SOURCES = ROOT / "identity-sources"
|
||||
PROFILE = Path(contract.PROFILE_ROOT)
|
||||
RECEIPT = ROOT / "metadata" / "artifact-receipt.json"
|
||||
IMAGE_INPUT = ROOT / "metadata" / "image-input.json"
|
||||
DOCKERFILE = ROOT / "metadata" / "Dockerfile"
|
||||
ENTRYPOINT = ROOT / "entrypoint.py"
|
||||
PACKAGE_CONTRACT = ROOT / "package_contract.py"
|
||||
RUNTIME_PERMISSIONS_VERIFIER = ROOT / "runtime_permissions_verifier.py"
|
||||
CA = Path(contract.CA_DESTINATION)
|
||||
READINESS = PROFILE / "state" / "readiness.json"
|
||||
PSQL = Path("/usr/bin/psql")
|
||||
RUNTIME_PYTHON = ROOT / "venv" / "bin" / "python"
|
||||
BOOTSTRAP = ARTIFACT / "bootstrap.py"
|
||||
STATE_DIRECTORIES = ("cache", "cron", "logs", "memories", "platforms", "sessions", "state")
|
||||
|
||||
|
||||
class EntrypointError(RuntimeError):
|
||||
"""The installed image or live process violates its release contract."""
|
||||
|
||||
|
||||
def _require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise EntrypointError(message)
|
||||
|
||||
|
||||
def _validate_installed() -> dict[str, Any]:
|
||||
image_input = contract.load_json(IMAGE_INPUT, "installed image input")
|
||||
contract.validate_installed_runtime_identity(
|
||||
image_input,
|
||||
artifact=ARTIFACT,
|
||||
receipt=RECEIPT,
|
||||
identity=IDENTITY,
|
||||
contract_source_root=ROOT,
|
||||
identity_source_root=IDENTITY_SOURCES,
|
||||
)
|
||||
_require(contract.sha256_file(CA) == contract.CA_SHA256, "installed Cloud SQL CA drifted")
|
||||
_require(platform.python_version() == contract.PYTHON_VERSION, "installed Python version drifted")
|
||||
_require(Path("/usr/bin/python3").resolve() == Path("/usr/local/bin/python3.11"), "system Python binding drifted")
|
||||
psql_version = subprocess.run(
|
||||
[str(PSQL), "--version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
).stdout.strip()
|
||||
_require(
|
||||
psql_version.startswith(f"psql (PostgreSQL) {contract.POSTGRES_VERSION}"), "installed psql version drifted"
|
||||
)
|
||||
bindings = image_input["source_bindings"]
|
||||
_require(contract.sha256_file(DOCKERFILE) == bindings["Dockerfile"], "installed Dockerfile binding drifted")
|
||||
_require(contract.sha256_file(ENTRYPOINT) == bindings["entrypoint.py"], "installed entrypoint binding drifted")
|
||||
_require(
|
||||
contract.sha256_file(PACKAGE_CONTRACT) == bindings["package_contract.py"],
|
||||
"installed package contract binding drifted",
|
||||
)
|
||||
_require(
|
||||
contract.sha256_file(RUNTIME_PERMISSIONS_VERIFIER) == bindings["runtime_permissions_verifier.py"],
|
||||
"installed runtime permissions verifier binding drifted",
|
||||
)
|
||||
for relative in contract.IDENTITY_CONTRACT_SOURCES:
|
||||
installed = ROOT / relative
|
||||
_require(
|
||||
contract.sha256_file(installed) == bindings[relative],
|
||||
f"installed identity contract source drifted: {relative}",
|
||||
)
|
||||
return image_input
|
||||
|
||||
|
||||
def _prepare_profile() -> None:
|
||||
_require(os.geteuid() == 0, "profile preparation requires the capability-limited container bootstrap user")
|
||||
_require(PROFILE.is_dir() and not PROFILE.is_symlink(), "runtime profile tmpfs is missing or unsafe")
|
||||
_require(not any(PROFILE.iterdir()), "runtime profile tmpfs is not empty")
|
||||
template = ARTIFACT / "profile-template"
|
||||
for source in template.iterdir():
|
||||
destination = PROFILE / source.name
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, destination, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, destination, follow_symlinks=False)
|
||||
for name in contract.RUNTIME_IDENTITY_FILES:
|
||||
shutil.copy2(IDENTITY / name, PROFILE / name, follow_symlinks=False)
|
||||
(PROFILE / name).chmod(0o600)
|
||||
for name in STATE_DIRECTORIES:
|
||||
path = PROFILE / name
|
||||
path.mkdir(mode=0o700)
|
||||
profile_entries = sorted(
|
||||
PROFILE.rglob("*"),
|
||||
key=lambda path: len(path.relative_to(PROFILE).parts),
|
||||
reverse=True,
|
||||
)
|
||||
for path in profile_entries:
|
||||
_require(not path.is_symlink(), "prepared runtime profile contains a symlink")
|
||||
for path in profile_entries:
|
||||
os.chown(path, contract.RUNTIME_UID, contract.RUNTIME_GID)
|
||||
runtime_identity = PROFILE / "bin" / "gcp-runtime-identity.json"
|
||||
os.chown(runtime_identity, 0, 0)
|
||||
runtime_identity.chmod(0o644)
|
||||
os.chown(PROFILE, contract.RUNTIME_UID, contract.RUNTIME_GID)
|
||||
|
||||
|
||||
def _drop_privileges() -> None:
|
||||
if os.geteuid() == contract.RUNTIME_UID and os.getegid() == contract.RUNTIME_GID:
|
||||
return
|
||||
_require(os.geteuid() == 0, "container bootstrap user is unexpected")
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
pr_capbset_drop = 24
|
||||
for capability in (0, 6, 7, 8): # CHOWN, SETGID, SETUID, SETPCAP (last)
|
||||
if libc.prctl(pr_capbset_drop, capability, 0, 0, 0) != 0:
|
||||
raise EntrypointError("container bootstrap capability drop failed")
|
||||
os.setgroups([])
|
||||
os.setgid(contract.RUNTIME_GID)
|
||||
os.setuid(contract.RUNTIME_UID)
|
||||
_require(os.geteuid() == contract.RUNTIME_UID and os.getegid() == contract.RUNTIME_GID, "privilege drop failed")
|
||||
|
||||
|
||||
def _runtime_environment() -> dict[str, str]:
|
||||
return {
|
||||
"HOME": str(PROFILE / "state"),
|
||||
"HERMES_HOME": str(PROFILE),
|
||||
"LANG": "C",
|
||||
"LC_ALL": "C",
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONNOUSERSITE": "1",
|
||||
"PYTHONSAFEPATH": "1",
|
||||
}
|
||||
|
||||
|
||||
def _exec_runtime(command: str) -> None:
|
||||
_prepare_profile()
|
||||
_drop_privileges()
|
||||
argv = [str(RUNTIME_PYTHON), str(BOOTSTRAP), command, "--profile", str(PROFILE)]
|
||||
if command == "service":
|
||||
argv.extend(["--readiness-file", str(READINESS)])
|
||||
os.execve(str(RUNTIME_PYTHON), argv, _runtime_environment())
|
||||
|
||||
|
||||
def _validate_zero_capabilities() -> None:
|
||||
status = Path("/proc/1/status").read_text(encoding="utf-8")
|
||||
fields = {}
|
||||
for line in status.splitlines():
|
||||
key, separator, value = line.partition(":")
|
||||
if separator:
|
||||
fields[key] = value.strip()
|
||||
for name in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"):
|
||||
_require(fields.get(name) == "0000000000000000", f"runtime process retains {name}")
|
||||
_require(fields.get("NoNewPrivs") == "1", "runtime process does not enforce no-new-privileges")
|
||||
_require(
|
||||
fields.get("Uid")
|
||||
== f"{contract.RUNTIME_UID}\t{contract.RUNTIME_UID}\t{contract.RUNTIME_UID}\t{contract.RUNTIME_UID}",
|
||||
"runtime uid drifted",
|
||||
)
|
||||
_require(
|
||||
fields.get("Gid")
|
||||
== f"{contract.RUNTIME_GID}\t{contract.RUNTIME_GID}\t{contract.RUNTIME_GID}\t{contract.RUNTIME_GID}",
|
||||
"runtime gid drifted",
|
||||
)
|
||||
|
||||
|
||||
def _validate_readiness_behavior(image_input: dict[str, Any], value: dict[str, Any]) -> None:
|
||||
expected_config_sha256 = image_input["runtime"]["config_sha256"]
|
||||
live_config = PROFILE / "config.yaml"
|
||||
_require(live_config.is_file() and not live_config.is_symlink(), "runtime profile config is missing or unsafe")
|
||||
_require(value.get("config_sha256") == expected_config_sha256, "runtime readiness config binding drifted")
|
||||
_require(contract.sha256_file(live_config) == expected_config_sha256, "runtime profile config drifted")
|
||||
_require(value.get("tool_surface") == contract.NO_SEND_TOOL_SURFACE, "runtime tool surface drifted")
|
||||
|
||||
|
||||
def _health(image_input: dict[str, Any]) -> dict[str, Any]:
|
||||
_drop_privileges()
|
||||
_validate_zero_capabilities()
|
||||
_require(READINESS.is_file() and not READINESS.is_symlink(), "runtime readiness receipt is missing or unsafe")
|
||||
_require(stat.S_IMODE(READINESS.stat().st_mode) == 0o600, "runtime readiness receipt mode drifted")
|
||||
value = contract.load_json(READINESS, "runtime readiness receipt")
|
||||
_require(
|
||||
set(value)
|
||||
== {
|
||||
"schema",
|
||||
"status",
|
||||
"process_id",
|
||||
"runtime_mode",
|
||||
"config_sha256",
|
||||
"gateway_adapter_count",
|
||||
"connected_platforms",
|
||||
"tool_surface",
|
||||
"identity_sha256",
|
||||
},
|
||||
"runtime readiness fields are not exact",
|
||||
)
|
||||
_require(value.get("schema") == "livingip.leocleanNoSendServiceReadiness.v1", "runtime readiness schema drifted")
|
||||
_require(
|
||||
value.get("status") == "ready" and value.get("process_id") == 1, "runtime readiness process binding drifted"
|
||||
)
|
||||
_require(value.get("runtime_mode") == "gcp_staging_no_send", "runtime readiness mode drifted")
|
||||
_require(
|
||||
value.get("gateway_adapter_count") == 0 and value.get("connected_platforms") == [],
|
||||
"runtime has a transport adapter",
|
||||
)
|
||||
_validate_readiness_behavior(image_input, value)
|
||||
expected_identity = {name: contract.sha256_file(PROFILE / name) for name in contract.RUNTIME_IDENTITY_FILES}
|
||||
_require(value.get("identity_sha256") == expected_identity, "runtime readiness identity binding drifted")
|
||||
cmdline = Path("/proc/1/cmdline").read_bytes().split(b"\0")
|
||||
expected_native = [
|
||||
str(RUNTIME_PYTHON).encode(),
|
||||
str(BOOTSTRAP).encode(),
|
||||
b"service",
|
||||
b"--profile",
|
||||
str(PROFILE).encode(),
|
||||
b"--readiness-file",
|
||||
str(READINESS).encode(),
|
||||
b"",
|
||||
]
|
||||
_require(contract.pid_one_command_line_is_exact(cmdline, expected_native), "runtime process command line drifted")
|
||||
return {
|
||||
"schema": "livingip.leocleanNoSendContainerHealth.v1",
|
||||
"status": "pass",
|
||||
"process_id": 1,
|
||||
"gateway_adapter_count": 0,
|
||||
"send_message_present": False,
|
||||
}
|
||||
|
||||
|
||||
def _verification_result(image_input: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "livingip.leocleanNoSendContainerVerification.v1",
|
||||
"status": "pass",
|
||||
"teleo_git_head": image_input["runtime"]["teleo_git_head"],
|
||||
"artifact_sha256": image_input["runtime"]["artifact_sha256"],
|
||||
"identity_sha256": image_input["identity"]["bundle_sha256"],
|
||||
"input_sha256": image_input["input_sha256"],
|
||||
"python_version": contract.PYTHON_VERSION,
|
||||
"postgres_version": contract.POSTGRES_VERSION,
|
||||
"cloudsql_ca_sha256": contract.CA_SHA256,
|
||||
}
|
||||
|
||||
|
||||
def _database_verification(image_input: dict[str, Any], run_id: str) -> dict[str, Any]:
|
||||
_drop_privileges()
|
||||
try:
|
||||
permissions_receipt = runtime_permissions_verifier.verify_runtime_permissions(
|
||||
run_id,
|
||||
credential_backend="metadata",
|
||||
execution_mode="container",
|
||||
)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise EntrypointError("container database verification failed") from None
|
||||
_require(
|
||||
isinstance(permissions_receipt, dict) and permissions_receipt.get("status") == "pass",
|
||||
"container database verification did not pass",
|
||||
)
|
||||
stable = {
|
||||
"schema": "livingip.leocleanNoSendContainerDatabaseVerification.v1",
|
||||
"status": "pass",
|
||||
"run_id": run_id,
|
||||
"container": {
|
||||
"teleo_git_head": image_input["runtime"]["teleo_git_head"],
|
||||
"artifact_sha256": image_input["runtime"]["artifact_sha256"],
|
||||
"identity_sha256": image_input["identity"]["bundle_sha256"],
|
||||
"input_sha256": image_input["input_sha256"],
|
||||
"runtime_permissions_verifier_sha256": image_input["source_bindings"][
|
||||
"runtime_permissions_verifier.py"
|
||||
],
|
||||
},
|
||||
"permissions": permissions_receipt,
|
||||
}
|
||||
return {**stable, "receipt_sha256": contract.canonical_sha256(stable)}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = argv if argv is not None else sys.argv[1:]
|
||||
arguments = arguments or ["service"]
|
||||
command = arguments[0]
|
||||
try:
|
||||
image_input = _validate_installed()
|
||||
if command in {"probe", "service"}:
|
||||
_require(len(arguments) == 1, "runtime command arguments are not exact")
|
||||
_exec_runtime(command)
|
||||
if command == "health":
|
||||
_require(len(arguments) == 1, "health command arguments are not exact")
|
||||
result = _health(image_input)
|
||||
elif command == "verify":
|
||||
_require(len(arguments) == 1, "verify command arguments are not exact")
|
||||
result = _verification_result(image_input)
|
||||
elif command == "verify-build":
|
||||
_require(len(arguments) == 5, "build verification arguments are not exact")
|
||||
expected = (
|
||||
image_input["runtime"]["teleo_git_head"],
|
||||
image_input["runtime"]["artifact_sha256"],
|
||||
image_input["identity"]["bundle_sha256"],
|
||||
image_input["input_sha256"],
|
||||
)
|
||||
_require(tuple(arguments[1:]) == expected, "image build arguments drifted from image input")
|
||||
result = _verification_result(image_input)
|
||||
elif command == "verify-database":
|
||||
_require(len(arguments) == 2, "database verification arguments are not exact")
|
||||
result = _database_verification(image_input, arguments[1])
|
||||
else:
|
||||
raise EntrypointError("unsupported container command")
|
||||
print(json.dumps(result, separators=(",", ":"), sort_keys=True))
|
||||
return 0
|
||||
except (EntrypointError, contract.PackageError, OSError, subprocess.SubprocessError):
|
||||
print(json.dumps({"status": "fail", "error": "container_contract_failed"}, sort_keys=True), file=sys.stderr)
|
||||
return 65
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue