feat: chat + optmz control panel
This commit is contained in:
@@ -14,6 +14,7 @@ frontend/node_modules/.vite/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
infra/local-prod/casan.local.env
|
||||
backend/.env
|
||||
frontend/.env
|
||||
**/.env
|
||||
|
||||
@@ -82,3 +82,13 @@
|
||||
{"timestamp":"2026-07-08T09:53:30Z","trace_id":"412d7566-4200-42a8-8772-b88bf395c1f1","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872","output_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872"}
|
||||
{"timestamp":"2026-07-08T09:53:32Z","trace_id":"506f4e4f-88f0-4a76-9561-b93acd65b026","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872","output_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872"}
|
||||
{"timestamp":"2026-07-08T09:53:33Z","trace_id":"4c96ed73-034e-4d15-b99b-12ef890372b0","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872","output_hash":"427aa6ae245aa0f05cc75c0aea61de8129aae1e0e589d5727873d4d7f9baf872"}
|
||||
{"timestamp":"2026-07-10T06:11:24Z","trace_id":"trace-1783663884-37","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
|
||||
{"timestamp":"2026-07-10T06:12:28Z","trace_id":"trace-1783663948-37","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
|
||||
{"timestamp":"2026-07-10T06:13:42Z","trace_id":"trace-1783664022-37","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
|
||||
{"timestamp":"2026-07-10T06:14:39Z","trace_id":"trace-1783664079-613","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"e756b35ca2e4c3326cada62e0a6d4b3d714c5afb0caab2268c86c63207228ed2","output_hash":"e756b35ca2e4c3326cada62e0a6d4b3d714c5afb0caab2268c86c63207228ed2"}
|
||||
{"timestamp":"2026-07-10T06:18:36Z","trace_id":"trace-1783664316-37","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
|
||||
{"timestamp":"2026-07-10T06:19:34Z","trace_id":"trace-1783664374-636","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"142404dfd1bd2e31f6bef54bb495685e059a9b5c4b2e3369c8b79f8c7bec67a9","output_hash":"142404dfd1bd2e31f6bef54bb495685e059a9b5c4b2e3369c8b79f8c7bec67a9"}
|
||||
{"timestamp":"2026-07-10T06:19:37Z","trace_id":"trace-1783664377-747","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab","output_hash":"5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab"}
|
||||
{"timestamp":"2026-07-10T06:20:20Z","trace_id":"trace-1783664420-1347","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"d1c3c3a7ee70994d948443ba05bb0a9c2dd1f1852a994ab80d722730d282ae44","output_hash":"d1c3c3a7ee70994d948443ba05bb0a9c2dd1f1852a994ab80d722730d282ae44"}
|
||||
{"timestamp":"2026-07-10T07:20:30Z","trace_id":"trace-1783668030-52","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"8aaecd06d85c7f743d3545e7b865f3053c647591f95919c1c0b003046d4757aa","output_hash":"8aaecd06d85c7f743d3545e7b865f3053c647591f95919c1c0b003046d4757aa"}
|
||||
{"timestamp":"2026-07-10T07:21:21Z","trace_id":"trace-1783668081-716","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"280cadd0be725e6496a8614b12a98836bedb5c8a38fc6b2a8afe5f44b237158e","output_hash":"280cadd0be725e6496a8614b12a98836bedb5c8a38fc6b2a8afe5f44b237158e"}
|
||||
|
||||
@@ -15,3 +15,11 @@
|
||||
{"timestamp":"2026-07-08T09:28:30Z","trace_id":"7622fc43-9b84-4216-a704-96384d9b9375","harness":"H6-agentops","agent":"unknown-agent","step":"agent_step","status":"success","exit_code":0,"latency_ms":224,"retry_count":0,"input_tokens":3,"output_tokens":3,"total_tokens":6,"cost_estimate":1.2e-05,"cost_source":"word_count_estimate","hallucination_signals":0,"alerts":[],"input_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4","output_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4"}
|
||||
{"timestamp":"2026-07-08T09:53:28Z","trace_id":"65ede58f-6400-49c1-b09e-e723035f7251","harness":"H6-agentops","agent":"unknown-agent","step":"agent_step","status":"success","exit_code":0,"latency_ms":219,"retry_count":0,"input_tokens":3,"output_tokens":3,"total_tokens":6,"cost_estimate":1.2e-05,"cost_source":"word_count_estimate","hallucination_signals":0,"alerts":[],"input_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4","output_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4"}
|
||||
{"timestamp":"2026-07-08T09:53:32Z","trace_id":"06a26538-e7c3-42c2-a202-fc90cf24924d","harness":"H6-agentops","agent":"unknown-agent","step":"agent_step","status":"success","exit_code":0,"latency_ms":243,"retry_count":0,"input_tokens":3,"output_tokens":3,"total_tokens":6,"cost_estimate":1.2e-05,"cost_source":"word_count_estimate","hallucination_signals":0,"alerts":[],"input_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4","output_hash":"7df5d60319af2916ff4d99c086c43b3989be3420fffff872e1077a650ca528f4"}
|
||||
{"timestamp": "2026-07-10T05:42:31Z", "trace_id": "ce743020-b73a-474a-871f-c465fb6eb472", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 159, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
|
||||
{"timestamp": "2026-07-10T06:10:20Z", "trace_id": "96b4c003-ecd4-4f2e-b861-8d48f085d645", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 141, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
|
||||
{"timestamp": "2026-07-10T06:11:24Z", "trace_id": "c924568f-2816-4ce0-b718-645b0c42733e", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 219, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
|
||||
{"timestamp": "2026-07-10T06:12:28Z", "trace_id": "10331f30-ce27-47c6-bc5a-9f9841ff8415", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "failed", "exit_code": 2, "latency_ms": 342, "retry_count": 0, "input_tokens": 5, "output_tokens": 5, "total_tokens": 10, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "4f16989d1b5e09312bbd78611adcdae1219a4b19ec40252a986369155f049f96"}
|
||||
{"timestamp": "2026-07-10T06:14:39Z", "trace_id": "2e9b93cb-f3a7-4ceb-accd-4e8001562d0a", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 57161, "retry_count": 0, "input_tokens": 5, "output_tokens": 52, "total_tokens": 57, "cost_estimate": 0.0, "cost_source": "readonly_word_count", "synthesis_mode": "deterministic", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "e756b35ca2e4c3326cada62e0a6d4b3d714c5afb0caab2268c86c63207228ed2"}
|
||||
{"timestamp": "2026-07-10T06:19:34Z", "trace_id": "d8796e51-f1bf-4950-b27d-5327ed69ee12", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 57864, "retry_count": 0, "input_tokens": 427, "output_tokens": 406, "total_tokens": 833, "cost_estimate": 0.0, "cost_source": "ollama_local_real_tokens", "synthesis_mode": "model", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "142404dfd1bd2e31f6bef54bb495685e059a9b5c4b2e3369c8b79f8c7bec67a9"}
|
||||
{"timestamp": "2026-07-10T06:20:20Z", "trace_id": "b5ad414a-4662-49c5-a024-3f2bb51ada64", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 43174, "retry_count": 0, "input_tokens": 511, "output_tokens": 288, "total_tokens": 799, "cost_estimate": 0.0, "cost_source": "ollama_local_real_tokens", "synthesis_mode": "model", "hallucination_signals": 0, "alerts": [], "input_hash": "5da5739dfab4b282139a20d7272d0e676331eb09f86f562cc5e6d1f3b668a5ab", "output_hash": "d1c3c3a7ee70994d948443ba05bb0a9c2dd1f1852a994ab80d722730d282ae44"}
|
||||
{"timestamp": "2026-07-10T07:21:22Z", "trace_id": "1fd5d347-f21c-4370-a60d-30b11fd8c53e", "harness": "H6-agentops", "agent": "chat.ask-casan", "step": "ask-casan-readonly", "status": "success", "exit_code": 0, "latency_ms": 51212, "retry_count": 0, "input_tokens": 362, "output_tokens": 59, "total_tokens": 421, "cost_estimate": 0.0, "cost_source": "ollama_local_real_tokens", "synthesis_mode": "model", "hallucination_signals": 0, "alerts": [], "input_hash": "8aaecd06d85c7f743d3545e7b865f3053c647591f95919c1c0b003046d4757aa", "output_hash": "280cadd0be725e6496a8614b12a98836bedb5c8a38fc6b2a8afe5f44b237158e"}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
{"timestamp":"2026-07-08T09:20:52Z","trace_id":"42b46ba8-3399-440e-aaee-da88dc06bc1c","harness":"L5-model-fallback","primary_exit":9,"route":"fallback","final_exit":0,"output":"/Users/thanhnguyen/Documents/AI/HarnessHkt/Harness_Hakathon/Output_CASAN5_REFINED/docs/output/casan/level5-evidence/10-fallback-output.txt"}
|
||||
{"timestamp":"2026-07-08T09:21:54Z","trace_id":"bc29e2dd-0930-4ded-9cd3-a011b5d24c4d","harness":"L5-model-fallback","primary_exit":1,"route":"fallback","final_exit":0,"output":"/var/folders/zn/qn8sqwzn18g34ddftsxgyz6r0000gn/T/tmp.2HkWTSLEMD/fb.out"}
|
||||
{"timestamp":"2026-07-10T06:09:41Z","trace_id":"ed6b7bc1-3030-4656-9097-69ba16f47e5e","harness":"L5-model-fallback","primary_exit":2,"route":"fallback","final_exit":0,"output":"/var/folders/zn/qn8sqwzn18g34ddftsxgyz6r0000gn/T/tmp.jBxa5B3jDQ/fb.out"}
|
||||
|
||||
@@ -2,3 +2,14 @@
|
||||
{"timestamp": "2026-07-08T09:20:56Z", "harness": "L5-provider-telemetry", "provider": "sample-provider", "model": "sample-model-large", "run_id": "provider-run-001", "step": "speckit.implement", "input_tokens": 1842, "output_tokens": 936, "total_tokens": 2778, "cost_usd": 0.08334, "latency_ms": 4210, "status": "success"}
|
||||
{"timestamp": "2026-07-08T09:22:30Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "judge", "role": "judge", "input_tokens": 168, "output_tokens": 3, "total_tokens": 171, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 1515, "status": "success"}
|
||||
{"timestamp": "2026-07-08T09:22:36Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "t4-telemetry-test", "role": "classify", "input_tokens": 208, "output_tokens": 2, "total_tokens": 210, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 1417, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:09:37Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 72, "output_tokens": 27, "total_tokens": 99, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 9972, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:09:40Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 218, "output_tokens": 3, "total_tokens": 221, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 2660, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:09:46Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 72, "output_tokens": 29, "total_tokens": 101, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 4034, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:13:44Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 216, "output_tokens": 2, "total_tokens": 218, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 2476, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:15:04Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 73, "output_tokens": 39, "total_tokens": 112, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 4967, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:18:39Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 216, "output_tokens": 2, "total_tokens": 218, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 2503, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:19:34Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 427, "output_tokens": 406, "total_tokens": 833, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 41620, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:19:38Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 216, "output_tokens": 2, "total_tokens": 218, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 626, "status": "success"}
|
||||
{"timestamp": "2026-07-10T06:20:20Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 511, "output_tokens": 288, "total_tokens": 799, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 31437, "status": "success"}
|
||||
{"timestamp": "2026-07-10T07:20:36Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 207, "output_tokens": 2, "total_tokens": 209, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 5868, "status": "success"}
|
||||
{"timestamp": "2026-07-10T07:21:21Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 362, "output_tokens": 59, "total_tokens": 421, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 4829, "status": "success"}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
# Codex Instructions for "OKR Web Application" Project
|
||||
|
||||
You are an expert full-stack developer specializing in NestJS and a **master of React with Vite and Tailwind CSS**. Your primary goal is to generate code that is secure, efficient, and **strictly follows this project's architecture and design documents**. Do not invent features or logic. Every piece of code must be traceable to a design specification.
|
||||
|
||||
## 1. The Supreme Rule: Adhere to Architecture & Design
|
||||
|
||||
**Before generating any code, you must understand the project's structure.**
|
||||
|
||||
- **Architecture:** Refer to `docs/technical_architecture.md` for the overall system design, module responsibilities, and technology stack.
|
||||
|
||||
- **Requirements:** Refer to `apps/okr/domain/input/okr-requirement.md` for functional requirements, use cases, and UI mockups.
|
||||
|
||||
- **Your Task:** Your role is to translate these designs into code, not to be creative.
|
||||
|
||||
**Example Check:**
|
||||
|
||||
- **CORRECT:** Logic for key result progress updates belongs in the `key-results` module.
|
||||
|
||||
- **INCORRECT:** Placing key result logic directly within the `objectives` service.
|
||||
|
||||
- **Typescript Types:** Use TypeScript types and interfaces as defined in the design documents. Do not create new types unless explicitly required by the design.
|
||||
|
||||
## 2. Key Technologies & Libraries to Prioritize
|
||||
|
||||
⚠️ **ABSOLUTE LIBRARY RESTRICTION - ZERO TOLERANCE POLICY:**
|
||||
- **FORBIDDEN:** Installing ANY new libraries beyond those already listed in package.json
|
||||
- **MANDATORY:** Use ONLY existing libraries from architecture.md dependencies
|
||||
- **EXCEPTION PROCESS:** If absolutely critical to add a new library, must:
|
||||
1. Stop all code generation
|
||||
2. Ask explicit permission from user
|
||||
3. Provide detailed justification of purpose and necessity
|
||||
4. Explain why existing libraries cannot fulfill the requirement
|
||||
5. Wait for user approval before proceeding
|
||||
- **FOCUS:** Maximize capabilities of existing libraries rather than seeking new ones
|
||||
|
||||
**Use only libraries from architecture.md, do not install new libraries.**
|
||||
|
||||
### Frontend Stack (React + Vite SPA):
|
||||
|
||||
| Library | Role |
|
||||
|---------|------|
|
||||
| **React 18.x** | UI framework |
|
||||
| **Vite 5.x** | Build tool & dev server with HMR |
|
||||
| **React Router DOM 6.x** | Client-side routing (`<Routes>`, `<Route>`, `<Link>`) |
|
||||
| **TanStack Query 5.x** | Server state management, caching (`useQuery`, `useMutation`) |
|
||||
| **Axios 1.x** | HTTP client — all API calls via `src/lib/api.ts` |
|
||||
| **React Hook Form 7.x** | Form state and submission |
|
||||
| **Zod 3.x** | Schema validation — schemas in `src/schemas/`, shared with backend DTOs |
|
||||
| **Tailwind CSS 3.x** | Utility-first styling — **only** styling tool, no CSS-in-JS |
|
||||
|
||||
### Backend Stack (NestJS):
|
||||
|
||||
| Library | Role |
|
||||
|---------|------|
|
||||
| **NestJS 10.x** | Application framework (modules, controllers, services, decorators) |
|
||||
| **Prisma 5.x** | ORM — `schema.prisma` is single source of truth, use Prisma client for all DB ops |
|
||||
| **@nestjs/jwt** | JWT sign/verify (no Passport — Username/Password only, no SSO) |
|
||||
| **bcrypt** | Password hashing (cost factor 12) |
|
||||
| **class-validator + class-transformer** | DTO validation with `ValidationPipe` |
|
||||
| **@nestjs/swagger** | OpenAPI/Swagger UI at `/api/docs` (dev only) |
|
||||
|
||||
## 2.1. Design Style Guidelines
|
||||
|
||||
**Design Style: Clean Modern Dashboard (Pure Tailwind CSS)**
|
||||
|
||||
This project uses **pure Tailwind CSS** — no external component library (no MUI, no Ant Design). The design is clean, professional, and matches the OKR dashboard wireframes defined in `apps/okr/domain/input/okr-requirement.md`.
|
||||
|
||||
### Layout Structure (matches OKR wireframes):
|
||||
|
||||
```
|
||||
+------------------+----------------------------------------------+
|
||||
| Sidebar (fixed) | Top Header (fixed) |
|
||||
| - Year nav +----------------------------------------------+
|
||||
| - My OKRs | Main Content Area (scrollable) |
|
||||
| - Members | |
|
||||
| - OKR - all | |
|
||||
+------------------+----------------------------------------------+
|
||||
```
|
||||
|
||||
### Color Palette:
|
||||
|
||||
| Color Name | Tailwind Class | Usage |
|
||||
|------------|----------------|-------|
|
||||
| **Background** | `bg-gray-50` | Page background |
|
||||
| **White** | `bg-white` | Cards, sidebar, content areas |
|
||||
| **Primary Text** | `text-gray-800` | Titles, important content |
|
||||
| **Secondary Text** | `text-gray-500` | Labels, descriptions, metadata |
|
||||
| **Primary Blue** | `bg-blue-600` / `text-blue-600` | Primary actions, active nav links |
|
||||
| **Success Green** | `text-green-600` / `bg-green-100` | Success status (100% / Completed) |
|
||||
| **Warning Orange** | `text-orange-500` / `bg-orange-100` | In-progress / pending status |
|
||||
| **Border** | `border-gray-200` | Card borders, dividers |
|
||||
| **Sidebar** | `bg-white border-r border-gray-200` | Left navigation panel |
|
||||
|
||||
### Component Style Rules:
|
||||
|
||||
1. **No CSS-in-JS** — no `sx` prop, no `styled()` — all styling via Tailwind utility classes.
|
||||
2. **Cards:** `bg-white rounded-xl shadow-sm border border-gray-200 p-6`
|
||||
3. **Primary button:** `bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors`
|
||||
4. **Secondary button:** `border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg transition-colors`
|
||||
5. **Input fields:** `w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500`
|
||||
6. **Select/Dropdown:** `border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500`
|
||||
7. **Status badges:**
|
||||
- Not Started: `bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-xs font-medium`
|
||||
- In Progress: `bg-orange-100 text-orange-700 px-2 py-1 rounded-full text-xs font-medium`
|
||||
- Completed: `bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs font-medium`
|
||||
8. **Progress bar:**
|
||||
```tsx
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-600 rounded-full h-2 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
```
|
||||
9. **Sidebar nav item (active):** `bg-blue-50 text-blue-600 font-medium`
|
||||
10. **Sidebar nav item (inactive):** `text-gray-600 hover:bg-gray-50 hover:text-gray-800`
|
||||
|
||||
### Consistent Visual Rules:
|
||||
|
||||
- All interactive elements must have hover effects (`hover:shadow-md`, `hover:-translate-y-px`, `transition-colors`).
|
||||
- Use consistent spacing: multiples of 4px (`p-4`, `gap-4`, `mt-6`).
|
||||
- Text must always use `gray` (not `grey`) — `text-gray-700`, `border-gray-200`.
|
||||
|
||||
## 2.2. Tailwind CSS Configuration Rules (Tailwind v3)
|
||||
|
||||
⚠️ **TAILWIND CSS v3 SPECIFIC REQUIREMENTS:**
|
||||
|
||||
### CSS Import Rules (v3):
|
||||
```css
|
||||
✅ CORRECT:
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
❌ WRONG: @import "tailwindcss";
|
||||
❌ WRONG: @import "tailwindcss/base";
|
||||
```
|
||||
|
||||
### Configuration File:
|
||||
```javascript
|
||||
// tailwind.config.ts
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### PostCSS Config:
|
||||
```javascript
|
||||
// postcss.config.js
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Color Class Rules:
|
||||
```css
|
||||
✅ CORRECT: text-gray-700, bg-gray-50, border-gray-200
|
||||
❌ WRONG: text-grey-700, bg-grey-50, border-grey-200
|
||||
```
|
||||
|
||||
### Layout Rules:
|
||||
```tsx
|
||||
✅ CORRECT: Use semantic HTML + Tailwind flex/grid
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-800">Title</h1>
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg">Action</button>
|
||||
</div>
|
||||
|
||||
❌ WRONG: Use third-party layout components
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h4">Title</Typography>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Hover State Rules:
|
||||
```css
|
||||
✅ CORRECT: hover:bg-gray-50, hover:bg-gray-100
|
||||
❌ WRONG: hover:bg-gray-25 (doesn't exist in Tailwind)
|
||||
```
|
||||
|
||||
### Custom Styles Rules:
|
||||
```css
|
||||
✅ CORRECT: Use standard CSS in index.css (no @apply with @layer)
|
||||
.okr-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
❌ WRONG: Use @apply with @layer
|
||||
@layer components {
|
||||
.okr-card { @apply bg-white rounded-xl border border-gray-200; }
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Backend Generation Rules (NestJS + Prisma)
|
||||
|
||||
### Controller Rules:
|
||||
|
||||
- **Location:** Must be in `backend/src/[module-name]/[module-name].controller.ts`.
|
||||
- **Responsibility:** Keep controllers "thin". They only receive requests, trigger guards, validate DTOs, and call a single service method.
|
||||
- **Auth:** Use `@UseGuards(JwtAuthGuard)` and `@Roles()` decorator on all protected routes.
|
||||
|
||||
### Service Rules:
|
||||
|
||||
- **Location:** Must be in `backend/src/[module-name]/[module-name].service.ts`.
|
||||
- **Responsibility:** All business logic lives here.
|
||||
- **Key Logic:**
|
||||
- Use Prisma client for all DB operations — **no raw SQL** in application code.
|
||||
- Filter data by role: ADMIN sees all, MANAGER sees all, EMPLOYEE sees only own objectives.
|
||||
- Throw specific NestJS exceptions (`NotFoundException`, `ForbiddenException`).
|
||||
|
||||
### Prisma Schema Rules:
|
||||
|
||||
- **Single source of truth:** `backend/prisma/schema.prisma` defines ALL tables.
|
||||
- **Migrations:** Use `npx prisma migrate dev --name <migration-name>` — never edit migration files manually.
|
||||
- **Types:** Always use Prisma-generated types (`Prisma.ObjectiveCreateInput`, `Prisma.KeyResultUpdateInput`).
|
||||
|
||||
### Module Structure (OKR Domain):
|
||||
|
||||
```
|
||||
backend/src/
|
||||
├── auth/ # JWT login, refresh token endpoints
|
||||
├── users/ # User CRUD (Admin/Manager only)
|
||||
├── objectives/ # Objective CRUD, filtering by quarter/owner/status
|
||||
├── key-results/ # KR CRUD, progress update (PATCH /:id/progress)
|
||||
└── common/ # Guards, filters, interceptors, decorators
|
||||
```
|
||||
|
||||
### Database Seed Management:
|
||||
|
||||
- **CRITICAL:** After completing backend code with schema changes, **ALWAYS** update the seed file.
|
||||
- **Seed file location:** `backend/prisma/seed.ts`
|
||||
- **Execution:** `npx prisma db seed` (or automatically on container start — always seeded in workshop environment)
|
||||
- **Idempotency:** Use Prisma `upsert` keyed on stable identifiers — running seed twice must produce no duplicates.
|
||||
|
||||
```typescript
|
||||
// backend/prisma/seed.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Always seed — workshop environment, no env gate needed
|
||||
|
||||
const passwordHash = await bcrypt.hash('Password@123', 10);
|
||||
|
||||
// Users — upsert keyed on email
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@okr.local' },
|
||||
update: {},
|
||||
create: { name: 'System Admin', email: 'admin@okr.local', password: passwordHash, role: 'ADMIN' },
|
||||
});
|
||||
|
||||
const manager = await prisma.user.upsert({
|
||||
where: { email: 'manager@okr.local' },
|
||||
update: {},
|
||||
create: { name: 'Nguyen Van Manager', email: 'manager@okr.local', password: passwordHash, role: 'MANAGER' },
|
||||
});
|
||||
|
||||
const employee = await prisma.user.upsert({
|
||||
where: { email: 'employee@okr.local' },
|
||||
update: {},
|
||||
create: { name: 'Nguyen Van A', email: 'employee@okr.local', password: passwordHash, role: 'EMPLOYEE' },
|
||||
});
|
||||
|
||||
// Objectives — upsert keyed on id
|
||||
const obj1 = await prisma.objective.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
title: 'POC AI for SQL Injection prevention',
|
||||
description: 'Evaluate AI tools for automated SQL injection detection',
|
||||
ownerId: employee.id,
|
||||
quarter: 'Q2/2026',
|
||||
status: 'IN_PROGRESS',
|
||||
},
|
||||
});
|
||||
|
||||
// Key Results — upsert keyed on id
|
||||
await prisma.keyResult.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
objectiveId: obj1.id,
|
||||
title: 'Complete 3 POC sessions with security team',
|
||||
progress: 0,
|
||||
startValue: 0,
|
||||
targetValue: 3,
|
||||
deadline: new Date('2026-06-30'),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seed completed successfully.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
```
|
||||
|
||||
### Seed Data Requirements (OKR Domain):
|
||||
|
||||
| Category | Records | Notes |
|
||||
|----------|---------|-------|
|
||||
| Users | ≥ 3 | Admin, Manager, Employee — password `Password@123` hashed with bcrypt |
|
||||
| Objectives | ≥ 2 | One per owner, varied statuses (`NOT_STARTED`, `IN_PROGRESS`) |
|
||||
| Key Results | ≥ 4 | Linked to objectives, with `progress`, `startValue`, `targetValue`, `deadline` |
|
||||
|
||||
- **When to update seed:** After adding new Prisma model fields, after changing relations, after completing any backend module.
|
||||
- **Run seed:** `docker-compose exec backend npx prisma db seed`
|
||||
|
||||
## 4. Frontend Generation Rules (React + Vite)
|
||||
|
||||
### Routing Rules (React Router DOM v6):
|
||||
|
||||
```tsx
|
||||
// frontend/src/App.tsx — route structure
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="objectives/new" element={<CreateObjective />} />
|
||||
<Route path="objectives/:id" element={<OKRDetail />} />
|
||||
<Route path="key-results/:id" element={<KeyResultDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
```
|
||||
|
||||
### Component & File Location Rules:
|
||||
|
||||
- **Route-level pages:** `frontend/src/pages/` (Login, Dashboard, OKRDetail, CreateObjective, KeyResultDetail)
|
||||
- **Layout components:** `frontend/src/components/layout/` (Sidebar, Header, AppLayout)
|
||||
- **Reusable UI components:** `frontend/src/components/ui/` (Button, ProgressBar, Badge, Table)
|
||||
- **Custom hooks:** `frontend/src/hooks/` (useAuth, useObjectives, useKeyResults)
|
||||
- **API client:** `frontend/src/lib/api.ts` — all Axios calls go here
|
||||
- **Query client config:** `frontend/src/lib/queryClient.ts`
|
||||
- **Zod schemas:** `frontend/src/schemas/`
|
||||
- **TypeScript interfaces:** `frontend/src/types/`
|
||||
|
||||
### Layout Construction Rules:
|
||||
|
||||
- **ONLY** use semantic HTML elements (`<div>`, `<nav>`, `<aside>`, `<main>`, `<section>`) + Tailwind classes.
|
||||
- **DO NOT** import any external component library for layout or UI.
|
||||
- **Example:**
|
||||
```tsx
|
||||
// ✅ CORRECT: semantic HTML + Tailwind
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-800">My OKRs</h1>
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700">
|
||||
+ New OKR
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Data Fetching & Mutation Rules:
|
||||
|
||||
- **`useQuery`:** Query key must be descriptive and include filter params.
|
||||
|
||||
```typescript
|
||||
// Query key examples
|
||||
['objectives', { quarter: 'Q2/2026', ownerId }]
|
||||
['objective', id]
|
||||
['key-results', objectiveId]
|
||||
```
|
||||
|
||||
- **`useMutation`:** Always invalidate relevant queries `onSuccess`.
|
||||
|
||||
```typescript
|
||||
const updateProgress = useMutation({
|
||||
mutationFn: (data: UpdateProgressDto) => api.updateKRProgress(krId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['objective', objectiveId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['objectives'] });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### API Call Rules:
|
||||
|
||||
- All functions that make network requests must be in `frontend/src/lib/api.ts`.
|
||||
- Components call functions from `lib/api.ts` — they **never** call Axios directly.
|
||||
- Base URL: `import.meta.env.VITE_API_BASE_URL` (e.g., `http://localhost:3000/api/v1`).
|
||||
- Auth tokens are in HttpOnly cookies — do **not** manually attach `Authorization` headers.
|
||||
|
||||
### Form Rules (React Hook Form + Zod):
|
||||
|
||||
```typescript
|
||||
// frontend/src/schemas/objective.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createObjectiveSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
ownerId: z.number().int().positive('Owner is required'),
|
||||
quarter: z.string().regex(/^Q[1-4]\/\d{4}$/, 'Format must be Q2/2026'),
|
||||
});
|
||||
|
||||
export type CreateObjectiveFormData = z.infer<typeof createObjectiveSchema>;
|
||||
|
||||
// Usage in component
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<CreateObjectiveFormData>({
|
||||
resolver: zodResolver(createObjectiveSchema),
|
||||
});
|
||||
```
|
||||
|
||||
## 5. TypeScript Type Safety Rules
|
||||
|
||||
**CRITICAL: Always verify types after generating code. Type safety is mandatory.**
|
||||
|
||||
### Type Checking Process:
|
||||
|
||||
1. **After generating any code, ALWAYS:**
|
||||
- Run TypeScript checks to ensure no type errors
|
||||
- Verify all imports have correct types
|
||||
- Check component props interfaces match usage
|
||||
- Ensure API response types align with frontend expectations
|
||||
- Validate DTOs consistency between frontend and backend
|
||||
|
||||
2. **Forbidden Patterns:**
|
||||
```typescript
|
||||
// ❌ NEVER use 'any' type
|
||||
const data: any = response.data;
|
||||
|
||||
// ❌ NEVER leave props untyped
|
||||
function Component(props) { ... }
|
||||
|
||||
// ❌ NEVER call Axios directly in components
|
||||
const response = await axios.get('/api/v1/objectives');
|
||||
```
|
||||
|
||||
3. **Required Patterns:**
|
||||
```typescript
|
||||
// ✅ Typed component props
|
||||
interface OKRCardProps {
|
||||
objective: Objective;
|
||||
onEdit?: (id: number) => void;
|
||||
}
|
||||
|
||||
// ✅ Standard API response envelope (matches backend)
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
meta?: { page: number; limit: number; total: number };
|
||||
}
|
||||
|
||||
// ✅ OKR domain types (frontend/src/types/okr.types.ts)
|
||||
interface Objective {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
ownerId: number;
|
||||
owner: User;
|
||||
quarter: string;
|
||||
status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
|
||||
keyResults: KeyResult[];
|
||||
}
|
||||
|
||||
interface KeyResult {
|
||||
id: number;
|
||||
objectiveId: number;
|
||||
title: string;
|
||||
progress: number;
|
||||
startValue: number;
|
||||
targetValue: number;
|
||||
deadline: string; // ISO date string
|
||||
}
|
||||
|
||||
// ✅ Backend DTO matching frontend interface
|
||||
export class CreateObjectiveDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
ownerId: number;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^Q[1-4]\/\d{4}$/)
|
||||
quarter: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Consistency Rules:
|
||||
|
||||
- **Frontend-Backend Alignment:** DTOs in backend must have matching interfaces in `frontend/src/types/`.
|
||||
- **Enum Consistency:** Role (`ADMIN | MANAGER | EMPLOYEE`) and Status (`NOT_STARTED | IN_PROGRESS | COMPLETED`) enums must be identical between frontend and backend.
|
||||
- **API Response Types:** Every API endpoint must have typed response interfaces matching the standard envelope `{ success, data, meta? }`.
|
||||
- **Component Props:** Every component must have a properly typed props interface.
|
||||
- **Zod Schemas:** Schemas in `frontend/src/schemas/` must align with backend `class-validator` rules on the corresponding DTO.
|
||||
|
||||
### Type Verification Checklist:
|
||||
|
||||
Before submitting any code, verify:
|
||||
- [ ] No `any` types used
|
||||
- [ ] All component props properly typed
|
||||
- [ ] API calls have typed parameters and responses
|
||||
- [ ] DTOs match between frontend/backend
|
||||
- [ ] Role/Status enum values consistent across codebase
|
||||
- [ ] Optional vs required properties correctly defined
|
||||
- [ ] Zod schemas align with backend `class-validator` rules
|
||||
@@ -2,7 +2,7 @@ FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y -qq python3 openssl bash && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update -qq && apt-get install -y -qq python3 python-is-python3 curl openssl bash && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/casan-control-panel/backend/package.json ./packages/casan-control-panel/backend/
|
||||
@@ -17,7 +17,7 @@ FROM node:20-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y -qq python3 openssl bash && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update -qq && apt-get install -y -qq python3 python-is-python3 curl openssl bash && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/casan-control-panel/backend/package.json ./packages/casan-control-panel/backend/
|
||||
@@ -25,6 +25,7 @@ COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/casan-control-panel/backend/dist
|
||||
COPY packages/casan-harness/scripts ./packages/casan-harness/scripts
|
||||
COPY packages/casan-harness/config ./packages/casan-harness/config
|
||||
COPY packages/casan-harness/security ./packages/casan-harness/security
|
||||
|
||||
WORKDIR /app/packages/casan-control-panel/backend
|
||||
|
||||
|
||||
@@ -16,6 +16,19 @@ services:
|
||||
CP_PORT: "3010"
|
||||
CASAN_APP_ROOT: /app
|
||||
CASAN_DASHBOARD_STALE_S: "315360000"
|
||||
# Optional local model gateway. Values come from infra/local-prod/casan.local.env
|
||||
# via local-full.sh and are never committed with credentials.
|
||||
# The Mac-hosted Ornith/Ollama model is the local default. If it is not
|
||||
# reachable, the governed chat falls back safely to deterministic answers.
|
||||
CASAN_CHAT_MODEL_MODE: ${CASAN_CHAT_MODEL_MODE:-model}
|
||||
CASAN_CHAT_MODEL_PROVIDER: ${CASAN_CHAT_MODEL_PROVIDER:-local}
|
||||
CASAN_OLLAMA_HOST: host.docker.internal:11434
|
||||
CASAN_ALLOW_DOCKER_HOST_OLLAMA: "1"
|
||||
OLLAMA_HOST: host.docker.internal:11434
|
||||
CASAN_OPENAI_COMPATIBLE_BASE_URL: ${CASAN_OPENAI_COMPATIBLE_BASE_URL:-}
|
||||
CASAN_OPENAI_COMPATIBLE_API_KEY: ${CASAN_OPENAI_COMPATIBLE_API_KEY:-}
|
||||
CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS: ${CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS:-}
|
||||
CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0}
|
||||
volumes:
|
||||
- ./.specify:/app/.specify
|
||||
- ./docs/output:/app/docs/output:ro
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# CASAN local full stack (macOS)
|
||||
|
||||
This guide runs the entire currently implemented CASAN stack on one Mac. It is
|
||||
a local production-like lab, not a production compliance claim. The Linux
|
||||
server is deliberately not used in this phase.
|
||||
|
||||
## What runs locally
|
||||
|
||||
| Component | Address | Role |
|
||||
|---|---|---|
|
||||
| Vault dev / Transit | `http://127.0.0.1:18200` | KMS signing and rotation exercises |
|
||||
| Mock OIDC / JWKS | `http://127.0.0.1:18081` | Approval JWT validation |
|
||||
| MinIO S3 API | `http://127.0.0.1:19090` | S3-compatible object storage |
|
||||
| MinIO Console | `http://127.0.0.1:19091` | S3 administration UI |
|
||||
| MinIO WORM bucket | `casan-worm` | Object Lock / 1-day compliance retention |
|
||||
| Alert mock | `http://127.0.0.1:19092` | Alert webhook target |
|
||||
| Billing mock | `http://127.0.0.1:19093/usage` | Provider-usage import target |
|
||||
| AgentOps dashboard | `http://127.0.0.1:18080` | Dashboard behind nginx basic auth |
|
||||
| CASAN Control Panel | `https://localhost:18443` | TLS + mock OIDC + oauth2-proxy + nginx + NestJS + React |
|
||||
| OmniRoute | `http://127.0.0.1:20128/v1` | Existing local OpenAI-compatible model gateway |
|
||||
|
||||
The Control Panel uses a different mock IdP port (`18082`) so it can run at the
|
||||
same time as the approval IdP (`18081`).
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- Docker Desktop running, with at least 6 GB RAM allocated.
|
||||
- Node.js 20+ and npm (needed only for host-side development and CASAN pipeline
|
||||
execution; the local services run in Docker).
|
||||
- OmniRoute already reachable at `http://127.0.0.1:20128/v1` when model mode is
|
||||
required.
|
||||
|
||||
Confirm Docker first:
|
||||
|
||||
```bash
|
||||
docker version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
## 2. Start every local service
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
bash packages/casan-harness/scripts/bash/local-full.sh start
|
||||
```
|
||||
|
||||
The first run builds the mock services and Control Panel images. Open:
|
||||
|
||||
- Dashboard: `http://127.0.0.1:18080` — credentials `casan` / `casan`.
|
||||
- MinIO Console: `http://127.0.0.1:19091` — credentials `casanadmin` /
|
||||
`casanadmin123`.
|
||||
- Control Panel: `https://localhost:18443` — accept the local self-signed
|
||||
certificate. The mock OIDC flow signs in automatically as `oidc-ops` with
|
||||
`org-admin` for local testing only.
|
||||
|
||||
Check running services at any time:
|
||||
|
||||
```bash
|
||||
bash packages/casan-harness/scripts/bash/local-full.sh status
|
||||
```
|
||||
|
||||
## 3. Verify infrastructure controls
|
||||
|
||||
```bash
|
||||
bash packages/casan-harness/scripts/bash/local-full.sh verify
|
||||
```
|
||||
|
||||
This performs live Vault Transit sign/verify, gets an RS256 JWT from the mock
|
||||
OIDC IdP and validates it via JWKS, writes a retained object to MinIO, sends an
|
||||
alert, imports billing telemetry, and accesses the authenticated dashboard.
|
||||
|
||||
For the stricter TLS/OIDC Control Panel browser smoke:
|
||||
|
||||
```bash
|
||||
bash packages/casan-harness/scripts/bash/local-full.sh smoke
|
||||
```
|
||||
|
||||
`smoke` is intentionally isolated and stops its temporary Control Panel stack
|
||||
when complete. Run `start` again if you want the UI to remain up afterwards.
|
||||
|
||||
## 4. Connect CASAN to OmniRoute
|
||||
|
||||
CASAN now supports an explicit OpenAI-compatible gateway model syntax. It does
|
||||
not relax the public OpenAI endpoint hardening: the custom URL is required and,
|
||||
by default, may only be localhost. Keep secrets out of shell history and Git.
|
||||
|
||||
First load your key into the current shell using the mechanism you normally use
|
||||
(for example a password manager or a non-tracked `.env.local`). Then list model
|
||||
IDs that OmniRoute exposes:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:20128/v1/models | python3 -m json.tool
|
||||
```
|
||||
|
||||
Configure a returned model ID. `CASAN_OPENAI_COMPATIBLE_API_KEY` may be a
|
||||
dedicated OmniRoute key; if omitted, the router falls back to `OPENAI_API_KEY`.
|
||||
|
||||
```bash
|
||||
export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://127.0.0.1:20128/v1
|
||||
export CASAN_OPENAI_COMPATIBLE_API_KEY="$OPENAI_API_KEY"
|
||||
export CASAN_MODEL_PRIMARY='openai-compatible:auto/best-coding'
|
||||
export CASAN_CHAT_MODEL_MODE=model
|
||||
export CASAN_CHAT_MODEL_PROVIDER=omniroute
|
||||
export CASAN_PREFLIGHT=1
|
||||
```
|
||||
|
||||
For the Dockerized Control Panel, copy the provided template and replace only
|
||||
the key locally. Docker uses `host.docker.internal` so the Linux container can
|
||||
reach OmniRoute on the Mac host.
|
||||
|
||||
```bash
|
||||
cp infra/local-prod/casan.local.env.example infra/local-prod/casan.local.env
|
||||
# Edit casan.local.env in your editor and replace the placeholder key.
|
||||
bash packages/casan-harness/scripts/bash/local-full.sh start
|
||||
```
|
||||
|
||||
Do not set a bare `localhost` URL in this file: inside the Control Panel
|
||||
container, `localhost` means the container itself, not your Mac.
|
||||
|
||||
Run a non-destructive router smoke. A successful response records real token
|
||||
usage in `.specify/logs/level5/provider-usage.jsonl`.
|
||||
|
||||
```bash
|
||||
printf 'Return exactly: CASAN_OK\n' >/tmp/casan-prompt.txt
|
||||
bash packages/casan-harness/scripts/bash/model-router.sh \
|
||||
/tmp/casan-prompt.txt /tmp/casan-model-result.json --role generate \
|
||||
--model "$CASAN_MODEL_PRIMARY"
|
||||
python3 -m json.tool /tmp/casan-model-result.json
|
||||
```
|
||||
|
||||
The configured `omniroute` chat provider is classified as a gateway and always
|
||||
forces CASAN's preflight data-governance check before prompt content is sent.
|
||||
This is intentional: OmniRoute could route a request to a cloud model even when
|
||||
the first selected model is local.
|
||||
|
||||
To use a gateway on a private-LAN host later, explicitly name it; never use an
|
||||
open allowlist:
|
||||
|
||||
```bash
|
||||
export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://192.168.1.5:20128/v1
|
||||
export CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS=192.168.1.5
|
||||
```
|
||||
|
||||
Use HTTPS and a real certificate before moving this beyond a trusted LAN.
|
||||
|
||||
### Local Ornith default for the Control Panel
|
||||
|
||||
The local Docker Control Panel now defaults to the existing host Ollama model
|
||||
`ornith:9b`. It reaches Docker Desktop's host bridge at
|
||||
`host.docker.internal:11434`; CASAN permits only that exact bridge when the
|
||||
local compose profile explicitly enables it. If Ollama is unavailable, chat
|
||||
returns its deterministic evidence answer instead of fabricating a model result.
|
||||
|
||||
Use OmniRoute/OpenAI only when you deliberately want the gateway route. Create
|
||||
`casan.local.env` as described above and set:
|
||||
|
||||
```bash
|
||||
CASAN_CHAT_MODEL_PROVIDER=omniroute
|
||||
CASAN_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:20128/v1
|
||||
CASAN_OPENAI_COMPATIBLE_API_KEY=your-key
|
||||
```
|
||||
|
||||
## 5. Run CASAN with local models
|
||||
|
||||
For model-backed source generation, use the same environment and enable it only
|
||||
for the invocation:
|
||||
|
||||
```bash
|
||||
CASAN_GEN_MODE=model CASAN_PREFLIGHT=1 \
|
||||
node scripts/casan-step.mjs 01-srs 1
|
||||
```
|
||||
|
||||
CASAN continues to fall back safely to deterministic templates if the gateway
|
||||
is unavailable or the output is rejected by a harness gate. For governed chat,
|
||||
start the Control Panel and select the `omniroute` provider through the existing
|
||||
model configuration; the default remains deterministic/offline.
|
||||
|
||||
## 6. Stop and reset
|
||||
|
||||
Stop all containers while preserving MinIO data:
|
||||
|
||||
```bash
|
||||
bash packages/casan-harness/scripts/bash/local-full.sh stop
|
||||
```
|
||||
|
||||
To remove only the lab's persisted object-store data, first stop the lab, then:
|
||||
|
||||
```bash
|
||||
docker volume rm casan-local-prod_minio-data
|
||||
```
|
||||
|
||||
This is destructive and cannot be undone. Do not use it if the MinIO bucket has
|
||||
evidence you need to retain.
|
||||
|
||||
## Current scope and next phase
|
||||
|
||||
This local environment includes every currently implemented CASAN component.
|
||||
It intentionally uses Vault dev mode, self-signed TLS, mock IdPs, local MinIO,
|
||||
and mock alert/billing endpoints. Production promotion to the Linux server
|
||||
should replace those with managed Vault/KMS or HSM, real S3 Object Lock, real
|
||||
OIDC, HTTPS certificates, a secret manager, backup/restore tests, and firewall
|
||||
rules; it is a separate deployment exercise.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy this file to casan.local.env, then replace the API-key value locally.
|
||||
# It is consumed only by local-full.sh and is ignored by Git.
|
||||
|
||||
CASAN_CHAT_MODEL_MODE=model
|
||||
CASAN_CHAT_MODEL_PROVIDER=omniroute
|
||||
CASAN_OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:20128/v1
|
||||
CASAN_OPENAI_COMPATIBLE_API_KEY=replace-with-your-omniroute-or-openai-key
|
||||
CASAN_PREFLIGHT=1
|
||||
@@ -27,6 +27,15 @@ export class ChatController {
|
||||
return ok(this.svc.verifyAudit());
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
history(
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
@Query('chatId') chatId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return ok(this.svc.history(actorFromHeaders(headers), chatId || '', Number(limit ?? 50)));
|
||||
}
|
||||
|
||||
@Get('replay')
|
||||
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string, @Query('tenant') tenant?: string) {
|
||||
return ok(this.svc.replay(chatId || '', turnId || '', tenant || ''));
|
||||
|
||||
@@ -95,6 +95,17 @@ export class ChatService {
|
||||
return { ok: res.status === 0, output: res.stdout || res.stderr };
|
||||
}
|
||||
|
||||
history(actor: SettingsActor, chatId = '', limit = 50) {
|
||||
this.requireRead(actor);
|
||||
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
|
||||
const args = ['history', '--actor', actor.actor, '--tenant', actor.tenant, '--limit', String(safeLimit)];
|
||||
if (chatId) args.push('--chat-id', chatId);
|
||||
const res = runPython(CHAT_CLI, args);
|
||||
const parsed = parseJson<Record<string, unknown>>(res.stdout);
|
||||
if (res.status === 0 && parsed?.ok === true) return parsed;
|
||||
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_HISTORY_FAILED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 3: streaming read-only/analysis turns. The harness emits two NDJSON
|
||||
* phases — an UNCERTIFIED deterministic draft, then the certified final. We
|
||||
|
||||
@@ -47,6 +47,23 @@ test('chat ask returns certified read-only answer with evidence sources', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('chat history returns only integrity-checked previews for the requesting actor', () => {
|
||||
withTempChatState(() => {
|
||||
const svc = new ChatService();
|
||||
svc.ask({ message: 'Summarize Plan 18 MVP-0 status', chatId: 'history-chat' }, viewer);
|
||||
svc.ask({ message: 'Summarize Plan 18 MVP-2 status', chatId: 'history-chat' }, viewer);
|
||||
svc.ask({ message: 'Summarize Plan 18 MVP-0 status', chatId: 'other-actor' }, { ...viewer, actor: 'someone-else' });
|
||||
|
||||
const history = svc.history(viewer, 'history-chat') as any;
|
||||
assert.equal(history.ok, true);
|
||||
assert.equal(history.turns.length, 2);
|
||||
assert.equal(history.turns[0].chat_id, 'history-chat');
|
||||
assert.ok(history.turns.every((turn: any) => turn.prompt_preview && turn.audit_hash));
|
||||
assert.equal(history.conversations.length, 1);
|
||||
assert.equal(history.conversations[0].chat_id, 'history-chat');
|
||||
});
|
||||
});
|
||||
|
||||
test('chat ask denies prompt injection and returns governed block response', () => {
|
||||
withTempChatState(() => {
|
||||
const svc = new ChatService();
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
export function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<div className="flex min-h-screen bg-slate-100">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-6 space-y-6">{children}</main>
|
||||
<main className="flex-1 overflow-auto px-4 py-5 pb-24 sm:px-6 lg:px-8 lg:py-7">
|
||||
<div className="mx-auto max-w-[1600px] space-y-5">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { health } from '../../lib/api';
|
||||
|
||||
const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
|
||||
'/': { title: 'Operations overview', eyebrow: 'System posture' },
|
||||
'/command': { title: 'Command center', eyebrow: 'Executive view' },
|
||||
'/chat': { title: 'Governed workspace', eyebrow: 'Ask CASAN' },
|
||||
'/runs': { title: 'Run observability', eyebrow: 'Execution ledger' },
|
||||
'/governance': { title: 'Governance ledger', eyebrow: 'Policy decisions' },
|
||||
'/security': { title: 'Security signals', eyebrow: 'H4 protection' },
|
||||
'/incidents': { title: 'Incident response', eyebrow: 'Containment' },
|
||||
'/traceability': { title: 'Traceability', eyebrow: 'Evidence graph' },
|
||||
'/finops': { title: 'FinOps & SLO', eyebrow: 'Model economics' },
|
||||
'/approvals': { title: 'Approval inbox', eyebrow: 'Human-in-the-loop' },
|
||||
'/settings': { title: 'Governed settings', eyebrow: 'Control plane' },
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const { data } = useQuery({ queryKey: ['health'], queryFn: health });
|
||||
const { pathname } = useLocation();
|
||||
const stale = data ? !data.ok : true;
|
||||
const page = PAGE_COPY[pathname] ?? PAGE_COPY['/'];
|
||||
return (
|
||||
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<h1 className="text-base font-semibold text-gray-800">CASAN Ops Console <span className="text-gray-400 font-normal">· read-only</span></h1>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">runs: {data?.runs ?? '—'}</span>
|
||||
<span className={`px-2 py-1 rounded-full font-medium ${stale ? 'bg-orange-100 text-orange-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{stale ? `STALE${data?.metrics_age_s != null ? ` (${data.metrics_age_s}s)` : ''}` : 'LIVE'}
|
||||
</span>
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200/80 bg-white/85 px-4 py-3 backdrop-blur-xl sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-[1600px] items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-600">{page.eyebrow}</div>
|
||||
<h1 className="mt-0.5 text-lg font-semibold tracking-tight text-slate-900">{page.title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-xs">
|
||||
<div className="hidden rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-slate-500 sm:block">
|
||||
<span className="font-medium text-slate-700">{data?.runs ?? '—'}</span> governed runs
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 rounded-xl border px-3 py-2 font-semibold ${stale ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${stale ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||
{stale ? `STALE${data?.metrics_age_s != null ? ` · ${data.metrics_age_s}s` : ''}` : 'LIVE TRUST'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
const NAV = [
|
||||
['/', 'Overview'], ['/runs', 'Runs'], ['/governance', 'Governance'],
|
||||
['/security', 'Security'], ['/incidents', 'Incidents'], ['/traceability', 'Traceability'],
|
||||
['/finops', 'FinOps'], ['/approvals', 'Approvals'], ['/settings', 'Settings'], ['/command', 'Command'], ['/chat', 'Chat'],
|
||||
|
||||
type IconName = 'grid' | 'command' | 'chat' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
|
||||
|
||||
interface NavItem { to: string; label: string; icon: IconName; }
|
||||
|
||||
const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
|
||||
{ label: 'Observe', items: [
|
||||
{ to: '/', label: 'Overview', icon: 'grid' },
|
||||
{ to: '/command', label: 'Command center', icon: 'command' },
|
||||
{ to: '/chat', label: 'Ask CASAN', icon: 'chat' },
|
||||
{ to: '/runs', label: 'Run observability', icon: 'runs' },
|
||||
] },
|
||||
{ label: 'Assure', items: [
|
||||
{ to: '/governance', label: 'Governance', icon: 'governance' },
|
||||
{ to: '/security', label: 'Security', icon: 'shield' },
|
||||
{ to: '/incidents', label: 'Incidents', icon: 'incident' },
|
||||
{ to: '/traceability', label: 'Traceability', icon: 'trace' },
|
||||
] },
|
||||
{ label: 'Control', items: [
|
||||
{ to: '/finops', label: 'FinOps & SLO', icon: 'coins' },
|
||||
{ to: '/approvals', label: 'Approvals', icon: 'approval' },
|
||||
{ to: '/settings', label: 'Settings', icon: 'settings' },
|
||||
] },
|
||||
];
|
||||
|
||||
function Icon({ name }: { name: IconName }) {
|
||||
const paths: Record<IconName, ReactNode> = {
|
||||
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
command: <><path d="M5 12h14M12 5l7 7-7 7" /><path d="M5 5v14" /></>,
|
||||
chat: <><path d="M20 11.5a7.5 7.5 0 0 1-8 7.5 8.4 8.4 0 0 1-3.7-.9L4 19l1.2-3.5A7.5 7.5 0 1 1 20 11.5Z" /><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" /></>,
|
||||
runs: <><path d="M4 19V9M10 19V5M16 19v-7M22 19H2" /><path d="M3 9h2M9 5h2M15 12h2" /></>,
|
||||
shield: <path d="M12 3 20 6v5c0 5.2-3.4 8.9-8 10-4.6-1.1-8-4.8-8-10V6l8-3Z" />,
|
||||
governance: <><path d="M4 20h16M6 17V9M10 17V5M14 17V9M18 17V5" /><path d="M3 5h18l-9-3-9 3Z" /></>,
|
||||
incident: <><path d="M10.3 3.3 2.7 17a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 3.3a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" /></>,
|
||||
trace: <><circle cx="6" cy="6" r="3" /><circle cx="18" cy="18" r="3" /><circle cx="18" cy="6" r="3" /><path d="m8.6 7.5 6.8 3M9 6h6" /></>,
|
||||
coins: <><ellipse cx="12" cy="5" rx="7" ry="3" /><path d="M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" /></>,
|
||||
approval: <><path d="M9 11 11 13l4-4" /><path d="M12 22c5-2.1 8-5.3 8-10V5l-8-3-8 3v7c0 4.7 3 7.9 8 10Z" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2 2-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5v.2h-2.8v-.2a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1-2-2 .1-.1A1.7 1.7 0 0 0 7.4 15a1.7 1.7 0 0 0-1.5-1H5.7v-2.8h.2a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9L7 8.2l2-2 .1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.5v-.2h2.8v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1 2 2-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.5 1h.2V14h-.2a1.7 1.7 0 0 0-1.5 1Z" /></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="h-[18px] w-[18px]">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
|
||||
<div className="h-14 flex items-center px-6 font-bold text-blue-600 border-b border-gray-200">CASAN</div>
|
||||
<nav className="p-3 space-y-1">
|
||||
{NAV.map(([to, label]) => (
|
||||
<NavLink key={to} to={to} end={to === '/'}
|
||||
className={({ isActive }) => `block px-3 py-2 rounded-lg text-sm ${isActive ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'}`}>
|
||||
{label}
|
||||
<>
|
||||
<aside className="sticky top-0 hidden h-screen w-[252px] shrink-0 flex-col border-r border-slate-800 bg-[#111827] text-slate-300 lg:flex">
|
||||
<div className="flex h-[76px] items-center border-b border-slate-800 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-400 to-indigo-600 font-bold text-white shadow-lg shadow-indigo-950/40">C</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-semibold tracking-[0.18em] text-white">CASAN</div>
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.13em] text-slate-500">Control plane</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-5">
|
||||
{NAVIGATION.map((group) => (
|
||||
<div key={group.label} className="mb-6 last:mb-0">
|
||||
<div className="px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">{group.label}</div>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition ${isActive ? 'bg-indigo-500/15 text-indigo-200 shadow-[inset_0_0_0_1px_rgba(129,140,248,0.16)]' : 'text-slate-400 hover:bg-slate-800/80 hover:text-slate-100'}`}
|
||||
>
|
||||
<span className="text-slate-500 transition group-hover:text-slate-300"><Icon name={item.icon} /></span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="m-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-300"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />Governed runtime</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-slate-500">Every action is policy checked and audit anchored.</p>
|
||||
</div>
|
||||
</aside>
|
||||
<nav className="fixed inset-x-3 bottom-3 z-30 flex items-center justify-around rounded-2xl border border-slate-200 bg-white/95 p-2 shadow-xl shadow-slate-900/10 backdrop-blur lg:hidden">
|
||||
{NAVIGATION[0].items.slice(0, 3).map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === '/'} className={({ isActive }) => `flex min-w-16 flex-col items-center gap-1 rounded-xl px-2 py-1.5 text-[10px] font-semibold ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'}`}>
|
||||
<Icon name={item.icon} />{item.label.split(' ')[0]}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/settings" className={({ isActive }) => `flex min-w-16 flex-col items-center gap-1 rounded-xl px-2 py-1.5 text-[10px] font-semibold ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'}`}>
|
||||
<Icon name="settings" />More
|
||||
</NavLink>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
export function Card({ title, children, right }: { title?: string; children: ReactNode; right?: ReactNode }) {
|
||||
export function Card({ title, children, right, className = '' }: { title?: string; children: ReactNode; right?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<section className={`rounded-2xl border border-slate-200/80 bg-white/95 p-5 shadow-[0_12px_30px_rgba(15,23,42,0.045)] backdrop-blur-sm sm:p-6 ${className}`}>
|
||||
{title && (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h2>
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.13em] text-slate-500">{title}</h2>
|
||||
{right}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="text-2xl font-semibold text-gray-800 mt-1">{value}</div>
|
||||
{sub && <div className="text-xs text-gray-400 mt-1">{sub}</div>}
|
||||
<div className="group rounded-2xl border border-slate-200/80 bg-white/90 p-4 shadow-[0_10px_24px_rgba(15,23,42,0.035)] transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(15,23,42,0.07)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">{value}</div>
|
||||
{sub && <div className="mt-1.5 text-xs leading-5 text-slate-400">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TONE: Record<string, string> = {
|
||||
ok: 'bg-green-100 text-green-700', pass: 'bg-green-100 text-green-700', success: 'bg-green-100 text-green-700', allow: 'bg-green-100 text-green-700',
|
||||
warn: 'bg-orange-100 text-orange-700', stale: 'bg-orange-100 text-orange-700',
|
||||
fail: 'bg-red-100 text-red-700', failed: 'bg-red-100 text-red-700', denied: 'bg-red-100 text-red-700', blocked: 'bg-red-100 text-red-700', deny: 'bg-red-100 text-red-700', block: 'bg-red-100 text-red-700', crit: 'bg-red-100 text-red-700',
|
||||
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', pass: 'border-emerald-200 bg-emerald-50 text-emerald-700', success: 'border-emerald-200 bg-emerald-50 text-emerald-700', allow: 'border-emerald-200 bg-emerald-50 text-emerald-700', approved: 'border-emerald-200 bg-emerald-50 text-emerald-700', answered: 'border-emerald-200 bg-emerald-50 text-emerald-700', certified: 'border-emerald-200 bg-emerald-50 text-emerald-700', verified: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warn: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
};
|
||||
export function StatusBadge({ value }: { value: string }) {
|
||||
const tone = TONE[String(value).toLowerCase()] ?? 'bg-gray-100 text-gray-600';
|
||||
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${tone}`}>{value}</span>;
|
||||
const tone = TONE[String(value).toLowerCase()] ?? 'border-slate-200 bg-slate-50 text-slate-600';
|
||||
return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none ${tone}`}>{value}</span>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #162033;
|
||||
background: #edf1f6;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
html, body, #root { min-height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 72% -10%, rgba(90, 130, 255, 0.13), transparent 30rem),
|
||||
#edf1f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
::selection { background: #c8d7ff; color: #14234b; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #c8d0dd; border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@@ -91,6 +91,9 @@ export interface ChatSource {
|
||||
|
||||
export interface ChatAnswer {
|
||||
success: boolean;
|
||||
chat_id?: string;
|
||||
turn_id?: string;
|
||||
trace_id?: string;
|
||||
mode: 'READ_ONLY' | 'OPERATOR' | 'BLOCK' | 'NOT_SUPPORTED' | string;
|
||||
risk: string;
|
||||
decision: 'ANSWERED' | 'ACTION_COMPLETED' | 'ACTION_FAILED' | 'REQUIRES_APPROVAL' | 'DENIED' | 'NOT_SUPPORTED' | string;
|
||||
@@ -148,6 +151,7 @@ export interface ChatAnswer {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cost_source?: string;
|
||||
fallback_from?: string | null;
|
||||
};
|
||||
actor: SettingsActor;
|
||||
}
|
||||
@@ -190,6 +194,34 @@ export interface ChatReplay {
|
||||
audit_path?: string;
|
||||
}
|
||||
|
||||
export interface ChatConversation {
|
||||
chat_id: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
turns: number;
|
||||
last_decision: string;
|
||||
last_mode: string;
|
||||
}
|
||||
|
||||
export interface ChatHistoryTurn {
|
||||
chat_id: string;
|
||||
turn_id: string;
|
||||
timestamp: string;
|
||||
mode: string;
|
||||
risk: string;
|
||||
decision: string;
|
||||
prompt_preview: string;
|
||||
answer_preview: string;
|
||||
certified: boolean;
|
||||
audit_hash: string;
|
||||
}
|
||||
|
||||
export interface ChatHistory {
|
||||
ok: boolean;
|
||||
conversations: ChatConversation[];
|
||||
turns: ChatHistoryTurn[];
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
actor: SettingsActor;
|
||||
capabilities: {
|
||||
@@ -277,6 +309,8 @@ export const api = {
|
||||
flush(buf);
|
||||
},
|
||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||
chatHistory: (actor: SettingsActor, chatId = '', limit = 50) =>
|
||||
getWithHeaders<ChatHistory>(`chat/history?chatId=${encodeURIComponent(chatId)}&limit=${limit}`, actorHeaders(actor)),
|
||||
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
|
||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
|
||||
|
||||
@@ -1,78 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { type KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatStreamPhase, SettingsActor } from '../lib/api';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatStreamPhase, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
|
||||
|
||||
function badgeValue(res: ChatAnswer | undefined, fallback = 'idle') {
|
||||
if (!res) return fallback;
|
||||
return `${res.mode} / ${res.risk}`;
|
||||
type MessageKind = 'user' | 'assistant' | 'draft';
|
||||
|
||||
interface WorkspaceMessage {
|
||||
id: string;
|
||||
kind: MessageKind;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
mode?: string;
|
||||
decision?: string;
|
||||
certified?: boolean;
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
function finalToAnswer(p: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
|
||||
function finalToAnswer(phase: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
|
||||
return {
|
||||
...(p as Partial<ChatAnswer>),
|
||||
success: p.decision === 'ANSWERED',
|
||||
mode: p.mode,
|
||||
risk: (p.risk as string) ?? 'low',
|
||||
decision: p.decision,
|
||||
answer: p.answer,
|
||||
sources: p.sources ?? [],
|
||||
certified: p.certified,
|
||||
audit: p.audit ?? {},
|
||||
audit_verify: p.audit_verify ?? { ok: true, output: '' },
|
||||
router: p.router ?? {},
|
||||
...(phase as Partial<ChatAnswer>),
|
||||
success: phase.decision === 'ANSWERED',
|
||||
mode: phase.mode,
|
||||
risk: phase.risk ?? 'low',
|
||||
decision: phase.decision,
|
||||
answer: phase.answer,
|
||||
sources: phase.sources ?? [],
|
||||
certified: phase.certified,
|
||||
audit: phase.audit ?? {},
|
||||
audit_verify: phase.audit_verify ?? { ok: true, output: '' },
|
||||
router: phase.router ?? {},
|
||||
actor,
|
||||
} as ChatAnswer;
|
||||
}
|
||||
|
||||
function auditHash(res: ChatAnswer) {
|
||||
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const candidate = error as { message?: string; response?: { data?: { message?: string } } };
|
||||
return candidate.response?.data?.message || candidate.message || 'The governed chat request could not be completed.';
|
||||
}
|
||||
return 'The governed chat request could not be completed.';
|
||||
}
|
||||
|
||||
function sourceExcerpt(source: { preview?: string; excerpt?: string }) {
|
||||
return source.preview || source.excerpt || '';
|
||||
function formatTime(value: string) {
|
||||
if (!value) return 'now';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat('en', { hour: '2-digit', minute: '2-digit' }).format(parsed);
|
||||
}
|
||||
|
||||
function auditHash(answer: ChatAnswer | null) {
|
||||
return answer?.audit?.hash || answer?.audit?.record_hash || answer?.audit?.head || 'n/a';
|
||||
}
|
||||
|
||||
function turnMessages(turn: ChatHistoryTurn): WorkspaceMessage[] {
|
||||
const messages: WorkspaceMessage[] = [];
|
||||
if (turn.prompt_preview) {
|
||||
messages.push({ id: `${turn.turn_id}-user`, kind: 'user', body: turn.prompt_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, preview: true });
|
||||
}
|
||||
if (turn.answer_preview) {
|
||||
messages.push({ id: `${turn.turn_id}-assistant`, kind: 'assistant', body: turn.answer_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, certified: turn.certified, preview: true });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function Glyph({ name }: { name: 'add' | 'send' | 'spark' | 'lock' | 'chevron' | 'bolt' | 'history' }) {
|
||||
const paths = {
|
||||
add: <><path d="M12 5v14M5 12h14" /></>,
|
||||
send: <><path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Z" /><path d="m9.7 13.3 4.6-4.6" /></>,
|
||||
spark: <><path d="m12 3 1.7 5.3L19 10l-5.3 1.7L12 17l-1.7-5.3L5 10l5.3-1.7L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
|
||||
lock: <><rect x="5" y="10" width="14" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>,
|
||||
chevron: <path d="m9 18 6-6-6-6" />,
|
||||
bolt: <path d="m13 2-9 12h7l-1 8 10-13h-7l0-7Z" />,
|
||||
history: <><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5M12 7v5l3 2" /></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const [actor, setActor] = useState<SettingsActor>({
|
||||
actor: 'local-operator',
|
||||
role: 'viewer',
|
||||
project: 'default',
|
||||
tenant: 'default',
|
||||
});
|
||||
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'viewer', project: 'default', tenant: 'default' });
|
||||
const [chatId, setChatId] = useState('chat-default');
|
||||
const [agentId, setAgentId] = useState('evidence-reader');
|
||||
const [skillId, setSkillId] = useState('evidence-summary');
|
||||
const [delegationLevel, setDelegationLevel] = useState(0);
|
||||
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
|
||||
const [message, setMessage] = useState('');
|
||||
const [last, setLast] = useState<ChatAnswer | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
const [draftText, setDraftText] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(true);
|
||||
const [streamBusy, setStreamBusy] = useState(false);
|
||||
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['chat-audit'],
|
||||
queryFn: api.verifyChatAudit,
|
||||
retry: false,
|
||||
});
|
||||
const actionsQuery = useQuery({
|
||||
queryKey: ['chat-actions', actor],
|
||||
queryFn: () => api.chatActions(actor),
|
||||
retry: false,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['chat-agents', actor],
|
||||
queryFn: () => api.chatAgents(actor),
|
||||
retry: false,
|
||||
});
|
||||
const auditQuery = useQuery({ queryKey: ['chat-audit'], queryFn: api.verifyChatAudit, retry: false });
|
||||
const conversationsQuery = useQuery({ queryKey: ['chat-history', actor], queryFn: () => api.chatHistory(actor), retry: false });
|
||||
const historyQuery = useQuery({ queryKey: ['chat-history', actor, chatId], queryFn: () => api.chatHistory(actor, chatId, 100), retry: false });
|
||||
const actionsQuery = useQuery({ queryKey: ['chat-actions', actor], queryFn: () => api.chatActions(actor), retry: false });
|
||||
const agentsQuery = useQuery({ queryKey: ['chat-agents', actor], queryFn: () => api.chatAgents(actor), retry: false });
|
||||
|
||||
const agents = agentsQuery.data?.agents ?? [];
|
||||
const selectedAgent = agents.find((a) => a.id === agentId) ?? agents.find((a) => a.allowed_for_role) ?? agents[0];
|
||||
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId)
|
||||
? skillId
|
||||
: (selectedAgent?.skills_allowed[0] ?? '');
|
||||
const selectedAgent = agents.find((agent) => agent.id === agentId) ?? agents.find((agent) => agent.allowed_for_role) ?? agents[0];
|
||||
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId) ? skillId : (selectedAgent?.skills_allowed[0] ?? '');
|
||||
const persistedMessages = useMemo(() => (historyQuery.data?.turns ?? []).flatMap(turnMessages), [historyQuery.data]);
|
||||
const liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id));
|
||||
const liveMessages: WorkspaceMessage[] = [];
|
||||
if (pendingMessage) liveMessages.push({ id: 'pending-user', kind: 'user', body: pendingMessage, timestamp: '', mode: 'READ_ONLY' });
|
||||
if (draftText) liveMessages.push({ id: 'draft', kind: 'draft', body: draftText, timestamp: '', mode: 'DRAFTING', certified: false });
|
||||
if (last && !liveAlreadyStored && !pendingMessage) liveMessages.push({ id: last.turn_id ?? 'latest-answer', kind: 'assistant', body: last.answer, timestamp: '', mode: last.mode, decision: last.decision, certified: last.certified });
|
||||
const messages = [...persistedMessages, ...liveMessages];
|
||||
|
||||
const refreshChat = () => {
|
||||
void auditQuery.refetch();
|
||||
void conversationsQuery.refetch();
|
||||
void historyQuery.refetch();
|
||||
};
|
||||
|
||||
const ask = useMutation({
|
||||
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
|
||||
@@ -82,296 +122,165 @@ export function Chat() {
|
||||
skillId: override?.skillId ?? selectedSkill,
|
||||
delegationLevel,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLast(res);
|
||||
onSuccess: (answer) => {
|
||||
setLast(answer);
|
||||
setPendingMessage(null);
|
||||
setError(null);
|
||||
void auditQuery.refetch();
|
||||
setMessage('');
|
||||
refreshChat();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err?.response?.data?.message || err.message || 'Ask CASAN failed');
|
||||
onError: (reason: unknown) => {
|
||||
setPendingMessage(null);
|
||||
setError(errorMessage(reason));
|
||||
},
|
||||
});
|
||||
|
||||
const runStream = async () => {
|
||||
const busy = ask.isPending || streamBusy;
|
||||
|
||||
const runStream = async (text: string) => {
|
||||
setStreamBusy(true);
|
||||
setError(null);
|
||||
setDraftText(null);
|
||||
setPendingMessage(text);
|
||||
try {
|
||||
await api.askChatStream(
|
||||
actor,
|
||||
{ message, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel },
|
||||
(phase: ChatStreamPhase) => {
|
||||
if (phase.phase === 'draft') {
|
||||
setDraftText(phase.answer);
|
||||
} else {
|
||||
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel }, (phase) => {
|
||||
if (phase.phase === 'draft') setDraftText(phase.answer);
|
||||
if (phase.phase === 'final') {
|
||||
setDraftText(null);
|
||||
setLast(finalToAnswer(phase, actor));
|
||||
setPendingMessage(null);
|
||||
setMessage('');
|
||||
}
|
||||
},
|
||||
);
|
||||
void auditQuery.refetch();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Stream failed');
|
||||
});
|
||||
refreshChat();
|
||||
} catch (reason: unknown) {
|
||||
setPendingMessage(null);
|
||||
setDraftText(null);
|
||||
setError(errorMessage(reason));
|
||||
} finally {
|
||||
setStreamBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAsk = () => {
|
||||
if (streaming) void runStream();
|
||||
else ask.mutate({});
|
||||
const submit = (override?: { message?: string; agentId?: string; skillId?: string }) => {
|
||||
const text = (override?.message ?? message).trim();
|
||||
if (!text || busy) return;
|
||||
if (streaming && !override) void runStream(text);
|
||||
else {
|
||||
setPendingMessage(text);
|
||||
ask.mutate(override ?? {});
|
||||
}
|
||||
};
|
||||
|
||||
const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const newConversation = () => {
|
||||
const id = `chat-${Date.now().toString(36)}`;
|
||||
setChatId(id);
|
||||
setLast(null);
|
||||
setMessage('');
|
||||
setPendingMessage(null);
|
||||
setDraftText(null);
|
||||
setError(null);
|
||||
};
|
||||
const busy = ask.isPending || streamBusy;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="Governed Chat"
|
||||
right={<StatusBadge value={last ? badgeValue(last) : (auditQuery.data?.ok ? 'audit ok' : 'ready')} />}
|
||||
>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-5 gap-4">
|
||||
<div className="xl:col-span-3 space-y-3">
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-gray-500">Ask CASAN</span>
|
||||
<textarea
|
||||
className="min-h-[132px] w-full rounded border border-gray-300 px-3 py-2 text-gray-800 focus:border-blue-400 focus:outline-none"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:bg-gray-300"
|
||||
disabled={!message.trim() || busy}
|
||||
onClick={onAsk}
|
||||
>
|
||||
{busy ? 'Asking...' : 'Ask'}
|
||||
<div className="space-y-5">
|
||||
<section className="relative overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-[#172554] via-[#1e2f68] to-[#334aa0] px-5 py-5 text-white shadow-[0_20px_40px_rgba(30,41,89,0.22)] sm:px-6">
|
||||
<div className="absolute -right-12 -top-16 h-52 w-52 rounded-full bg-indigo-300/20 blur-3xl" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200"><Glyph name="spark" />Evidence-first assistant</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Ask with context. Act only with proof.</h2>
|
||||
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-indigo-100/80">Every response is routed, checked and anchored to an auditable evidence trail before it reaches this workspace.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-xs font-semibold text-indigo-50 backdrop-blur">
|
||||
<span className={`h-2 w-2 rounded-full ${auditQuery.data?.ok ? 'bg-emerald-300' : 'bg-amber-300'}`} />
|
||||
{auditQuery.data?.ok ? 'Audit chain verified' : 'Checking audit chain'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid min-h-[680px] grid-cols-1 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_18px_45px_rgba(15,23,42,0.07)] xl:grid-cols-[260px_minmax(0,1fr)_300px]">
|
||||
<aside className="border-b border-slate-200 bg-slate-50/80 p-4 xl:border-b-0 xl:border-r">
|
||||
<button type="button" onClick={newConversation} className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-3 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700 disabled:bg-slate-400" disabled={busy}>
|
||||
<Glyph name="add" />New conversation
|
||||
</button>
|
||||
<div className="mt-5 flex items-center justify-between text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400"><span>Recent threads</span><span>{conversationsQuery.data?.conversations.length ?? 0}</span></div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{(conversationsQuery.data?.conversations ?? []).map((conversation) => (
|
||||
<button key={conversation.chat_id} type="button" onClick={() => { setChatId(conversation.chat_id); setLast(null); setError(null); }} className={`w-full rounded-xl p-3 text-left transition ${chatId === conversation.chat_id ? 'bg-white shadow-sm ring-1 ring-indigo-200' : 'hover:bg-white/70'}`}>
|
||||
<div className="flex items-start justify-between gap-2"><div className="line-clamp-2 text-sm font-medium leading-5 text-slate-800">{conversation.title}</div><StatusBadge value={conversation.last_decision} /></div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-400"><span>{conversation.turns} turn{conversation.turns === 1 ? '' : 's'}</span><span>{formatTime(conversation.updated_at)}</span></div>
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={streaming} onChange={(e) => setStreaming(e.target.checked)} />
|
||||
Stream
|
||||
</label>
|
||||
<StatusBadge value="read-only" />
|
||||
</div>
|
||||
{draftText && (
|
||||
<div className="rounded border border-orange-200 bg-orange-50 p-3 text-sm text-gray-700">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StatusBadge value="draft" />
|
||||
<span className="text-xs text-orange-700">UNCERTIFIED — awaiting H4/certify</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap leading-6">{draftText}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Actor</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
||||
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Role</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
||||
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Project</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
|
||||
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Tenant</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
|
||||
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2 xl:col-span-1">
|
||||
<span className="text-gray-500">Chat ID</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2 xl:col-span-1">
|
||||
<span className="text-gray-500">Agent</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedAgent?.id ?? agentId}
|
||||
onChange={(e) => {
|
||||
const next = agents.find((a) => a.id === e.target.value);
|
||||
setAgentId(e.target.value);
|
||||
setSkillId(next?.skills_allowed[0] ?? '');
|
||||
}}>
|
||||
{agents.map((a: ChatAgent) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label}{a.allowed_for_role ? '' : ' (locked)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Skill</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedSkill}
|
||||
onChange={(e) => setSkillId(e.target.value)}>
|
||||
{(selectedAgent?.skills_allowed ?? []).map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Delegation</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" type="number" min={0} max={5}
|
||||
value={delegationLevel} onChange={(e) => setDelegationLevel(Number(e.target.value || 0))} />
|
||||
</label>
|
||||
{!conversationsQuery.isLoading && (conversationsQuery.data?.conversations.length ?? 0) === 0 && <div className="rounded-xl border border-dashed border-slate-200 p-4 text-xs leading-5 text-slate-500">Start a conversation to create an immutable, audit-safe thread.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="mt-6 rounded-xl border border-indigo-100 bg-indigo-50/70 p-3 text-xs leading-5 text-indigo-800"><div className="flex items-center gap-1.5 font-semibold"><Glyph name="history" />Memory boundary</div><p className="mt-1 text-indigo-700/80">Only H4-scanned previews are restored. Raw prompts remain outside the UI history.</p></div>
|
||||
</aside>
|
||||
|
||||
{last && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<Card
|
||||
title="Answer"
|
||||
right={<StatusBadge value={last.certified ? 'certified' : 'uncertified'} />}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<StatusBadge value={last.mode} />
|
||||
<StatusBadge value={last.risk} />
|
||||
<StatusBadge value={last.decision} />
|
||||
{last.synthesis && (
|
||||
<StatusBadge value={last.synthesis.mode === 'model'
|
||||
? `model: ${last.synthesis.provider ?? 'provider'}`
|
||||
: 'deterministic'} />
|
||||
)}
|
||||
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
|
||||
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
|
||||
{last.loop_run && <StatusBadge value={last.loop_run.draft_certified ? 'loop certified' : 'loop held'} />}
|
||||
<StatusBadge value={last.audit_verify.ok ? 'audit ok' : 'audit fail'} />
|
||||
<section className="flex min-h-[620px] min-w-0 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3.5">
|
||||
<div><div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Active thread</div><div className="mt-0.5 font-mono text-xs text-slate-700">{chatId}</div></div>
|
||||
<div className="flex items-center gap-2"><StatusBadge value={streaming ? 'streaming' : 'certified only'} /><StatusBadge value={selectedAgent?.mode ?? 'READ_ONLY'} /></div>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 text-gray-800">{last.answer}</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-gray-400">audit hash</div>
|
||||
<div className="font-medium text-gray-700 break-all">{auditHash(last)}</div>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto bg-[linear-gradient(180deg,#fff_0%,#fafcff_100%)] px-5 py-6">
|
||||
{historyQuery.isLoading && <div className="text-sm text-slate-400">Loading audit-safe conversation history…</div>}
|
||||
{messages.map((item) => (
|
||||
<div key={item.id} className={`flex ${item.kind === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<article className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[78%] ${item.kind === 'user' ? 'rounded-br-md bg-slate-900 text-white shadow-md shadow-slate-900/10' : item.kind === 'draft' ? 'rounded-bl-md border border-amber-200 bg-amber-50 text-slate-700' : 'rounded-bl-md border border-slate-200 bg-white text-slate-800 shadow-sm'}`}>
|
||||
<div className={`mb-2 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.12em] ${item.kind === 'user' ? 'text-slate-300' : item.kind === 'draft' ? 'text-amber-700' : 'text-slate-400'}`}>
|
||||
{item.kind === 'user' ? 'You' : item.kind === 'draft' ? 'Uncertified draft' : 'CASAN'}
|
||||
{item.mode && <span className="font-medium normal-case tracking-normal">· {item.mode}</span>}
|
||||
{item.preview && <span className="font-medium normal-case tracking-normal">· audit preview</span>}
|
||||
</div>
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-gray-400">router</div>
|
||||
<div className="font-medium text-gray-700">{last.router?.reason ?? 'n/a'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Evidence">
|
||||
<div className="space-y-3">
|
||||
{last.sources.map((s) => (
|
||||
<div key={`${s.path}-${s.line ?? s.hash ?? s.score}`} className="rounded border border-gray-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-800 truncate">{s.title || s.path}</div>
|
||||
<div className="text-xs text-gray-400 break-all">{s.path}{s.line ? `:${s.line}` : ''}</div>
|
||||
</div>
|
||||
<StatusBadge value={`score ${s.score}`} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-gray-600">{sourceExcerpt(s)}</div>
|
||||
<div className="mt-2 text-xs text-gray-400 break-all">
|
||||
{s.hash ? `hash ${s.hash}` : s.envelope?.verified ? 'verified source' : 'source'}
|
||||
<div className="whitespace-pre-wrap text-sm leading-6">{item.body}</div>
|
||||
<div className={`mt-3 flex items-center gap-2 text-[10px] ${item.kind === 'user' ? 'text-slate-400' : 'text-slate-400'}`}>
|
||||
<span>{formatTime(item.timestamp)}</span>
|
||||
{item.decision && <span>· {item.decision}</span>}
|
||||
{item.certified !== undefined && <span>· {item.certified ? 'certified' : 'held'}</span>}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
{last.sources.length === 0 && <div className="text-sm text-gray-500">No evidence source returned.</div>}
|
||||
{!historyQuery.isLoading && messages.length === 0 && <div className="mx-auto flex max-w-md flex-col items-center py-20 text-center"><div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600"><Glyph name="spark" /></div><h3 className="mt-4 font-semibold text-slate-800">A governed empty state</h3><p className="mt-2 text-sm leading-6 text-slate-500">Ask for a plan, a security posture, or an evidence-backed comparison. CASAN will cite what it knows and decline what it cannot govern.</p></div>}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="border-t border-slate-200 bg-white p-4">
|
||||
{error && <div role="alert" className="mb-3 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>}
|
||||
<div className="rounded-2xl border border-slate-300 bg-white p-2 shadow-[0_8px_20px_rgba(15,23,42,0.05)] transition focus-within:border-indigo-400 focus-within:ring-4 focus-within:ring-indigo-100">
|
||||
<textarea aria-label="Ask CASAN" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={onComposerKeyDown} placeholder="Ask CASAN about your evidence, plans or governed actions…" className="min-h-[82px] w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-6 text-slate-800 outline-none placeholder:text-slate-400" disabled={busy} />
|
||||
<div className="flex items-center justify-between gap-3 px-1 pt-1">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500"><input type="checkbox" checked={streaming} onChange={(event) => setStreaming(event.target.checked)} className="h-3.5 w-3.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />Show safe draft first</label>
|
||||
<button type="button" onClick={() => submit()} disabled={!message.trim() || busy} className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"><Glyph name="send" />{busy ? 'Working…' : 'Ask CASAN'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-400"><Glyph name="lock" />Enter sends · Shift + Enter adds a line · outputs pass governance before certification.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card title="Router">
|
||||
<div className="space-y-3 text-sm">
|
||||
{last.action && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Operator action</div>
|
||||
<div className="mt-1 font-medium text-gray-800">{last.action.label}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{last.action.description}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.action.id} />
|
||||
<StatusBadge value={last.action_gate?.outcome ?? 'gate'} />
|
||||
<aside className="border-t border-slate-200 bg-slate-50/70 p-4 xl:border-l xl:border-t-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div>
|
||||
<div className="mt-3 rounded-xl border border-slate-200 bg-white p-3.5">
|
||||
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold text-slate-800">{selectedAgent?.label ?? 'Evidence reader'}</div><StatusBadge value={selectedAgent?.allowed_for_role ? 'allowed' : 'locked'} /></div>
|
||||
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'}</div>
|
||||
<details className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-600"><summary className="cursor-pointer font-medium text-slate-700">Session scope</summary><div className="mt-3 grid grid-cols-2 gap-2"><label className="col-span-2">Actor<input value={actor.actor} onChange={(event) => setActor({ ...actor, actor: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Role<select value={actor.role} onChange={(event) => setActor({ ...actor, role: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{ROLES.map((role) => <option key={role}>{role}</option>)}</select></label><label>Delegate<input type="number" min={0} max={5} value={delegationLevel} onChange={(event) => setDelegationLevel(Number(event.target.value || 0))} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Project<input value={actor.project} onChange={(event) => setActor({ ...actor, project: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Tenant<input value={actor.tenant} onChange={(event) => setActor({ ...actor, tenant: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label className="col-span-2">Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label><label className="col-span-2">Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label></div></details>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.agent_binding && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Agent binding</div>
|
||||
<div className="mt-1 font-medium text-gray-800">{last.agent_binding.agent_selected}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{last.agent_binding.skill_selected || 'no skill'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.agent_binding.decision} />
|
||||
<StatusBadge value={last.agent_binding.model_role ?? 'model'} />
|
||||
<StatusBadge value={`tools ${last.agent_binding.tool_allowlist.length}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.loop_run && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Loop run</div>
|
||||
<div className="mt-1 font-medium text-gray-800 break-all">{last.loop_run.run_id}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.loop_run.decision} />
|
||||
<StatusBadge value={last.loop_run.draft_certified ? 'draft certified' : 'draft held'} />
|
||||
<StatusBadge value={last.loop_run.side_effect_released ? 'released' : 'held'} />
|
||||
<StatusBadge value={last.loop_run.replay?.ok ? 'replay ok' : 'replay pending'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.codegen && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Codegen draft</div>
|
||||
<div className="mt-1 font-medium text-gray-800 break-all">{last.codegen.artifact ?? 'n/a'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.codegen.artifact_scan?.ok ? 'artifact scan ok' : 'artifact scan held'} />
|
||||
<StatusBadge value={last.codegen.tool_output_scan?.ok ? 'output scan ok' : 'output scan held'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(last.router?.matched_rules ?? []).map((r) => <StatusBadge key={r} value={r} />)}
|
||||
{(last.router?.matched_rules ?? []).length === 0 && <span className="text-gray-500">none</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Gates</div>
|
||||
<div className="mt-1 text-gray-700">{(last.router?.gates ?? []).join(', ') || 'n/a'}</div>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">{JSON.stringify(last.router, null, 2)}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Registered operator actions">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{(actionsQuery.data?.actions ?? []).map((action: ChatAction) => {
|
||||
const trigger = action.triggers[0] || action.id;
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
disabled={ask.isPending}
|
||||
onClick={() => {
|
||||
setMessage(trigger);
|
||||
ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' });
|
||||
}}
|
||||
className="text-left rounded border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
<div className="font-medium text-gray-800">{action.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-gray-500">{action.description}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{action.triggers.slice(0, 2).map((t) => <StatusBadge key={t} value={t} />)}
|
||||
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Latest verification</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3"><div className="text-xs text-slate-400">Audit anchor</div><div className="mt-1 break-all font-mono text-[11px] text-slate-700">{auditHash(last).slice(0, 22)}{auditHash(last) !== 'n/a' ? '…' : ''}</div></div>
|
||||
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div></div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Registered actions</div>
|
||||
<div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div>
|
||||
</aside>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{actionsQuery.isError && <div className="text-sm text-red-600">Cannot load registered operator actions.</div>}
|
||||
{!actionsQuery.isLoading && !actionsQuery.isError && (actionsQuery.data?.actions ?? []).length === 0 && (
|
||||
<div className="text-sm text-gray-500">No operator actions registered.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,36 +2,71 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
function SignalMark({ tone }: { tone: 'indigo' | 'emerald' | 'amber' }) {
|
||||
const color = { indigo: 'bg-indigo-500', emerald: 'bg-emerald-500', amber: 'bg-amber-500' }[tone];
|
||||
return <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${color}`} />;
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['overview'], queryFn: api.overview });
|
||||
if (isLoading) return <div className="text-gray-500">Loading…</div>;
|
||||
if (isError || !data) return <div className="text-red-600">Cannot reach Ops Console API.</div>;
|
||||
const t = data.totals;
|
||||
if (isLoading) return <div className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-500">Loading operational signals…</div>;
|
||||
if (isError || !data) return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700">Cannot reach the Ops Console API.</div>;
|
||||
const totals = data.totals;
|
||||
const signalEntries = Object.entries(data.harness_signals);
|
||||
const posture = totals.failures > 0 || totals.action_blocks > 0 ? 'attention' : 'verified';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatTile label="Runs" value={t.runs} />
|
||||
<StatTile label="Failures" value={t.failures} />
|
||||
<StatTile label="Total cost (est)" value={`$${t.total_cost.toFixed(4)}`} sub={`${t.provider_tokens} provider tokens`} />
|
||||
<StatTile label="Avg latency" value={`${t.avg_latency_ms} ms`} />
|
||||
<StatTile label="Fallback routes" value={t.fallback_routes} />
|
||||
<StatTile label="Tool denies" value={t.tool_denies} />
|
||||
<StatTile label="Action blocks" value={t.action_blocks} />
|
||||
<StatTile label="Hallucination signals" value={t.hallucination_signals} />
|
||||
<div className="space-y-5">
|
||||
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_15px_35px_rgba(15,23,42,0.055)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="relative overflow-hidden px-5 py-6 sm:px-7">
|
||||
<div className="absolute right-0 top-0 h-40 w-40 translate-x-1/3 -translate-y-1/3 rounded-full bg-indigo-100 blur-2xl" />
|
||||
<div className="relative">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-600">CASAN system posture</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">Governance is visible. Evidence is actionable.</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">Monitor the health of every controlled run, policy decision and model interaction from one evidence-backed control plane.</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2"><StatusBadge value={posture} /><span className="text-xs text-slate-500">{data.audit_chain.records} audit records · head anchored {data.audit_chain.head ? 'now' : 'pending'}</span></div>
|
||||
</div>
|
||||
<Card title="Harness signals (real counts)">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
{Object.entries(data.harness_signals).map(([h, sig]) => (
|
||||
<div key={h} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="font-medium text-gray-700">{h}</div>
|
||||
<div className="text-gray-500 mt-1">{Object.entries(sig).map(([k, v]) => `${k}: ${v}`).join(' · ')}</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 bg-slate-50/80 p-5 lg:border-l lg:border-t-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Trust ribbon</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex gap-3"><SignalMark tone="emerald" /><div><div className="text-sm font-semibold text-slate-800">Audit chain</div><div className="text-xs text-slate-500">{data.audit_chain.records ? 'Verified telemetry present' : 'Awaiting first record'}</div></div></div>
|
||||
<div className="flex gap-3"><SignalMark tone={totals.provider_tokens > 0 ? 'indigo' : 'amber'} /><div><div className="text-sm font-semibold text-slate-800">Model telemetry</div><div className="text-xs text-slate-500">{totals.provider_tokens.toLocaleString()} provider tokens observed</div></div></div>
|
||||
<div className="flex gap-3"><SignalMark tone={totals.failures > 0 ? 'amber' : 'emerald'} /><div><div className="text-sm font-semibold text-slate-800">Execution gate</div><div className="text-xs text-slate-500">{totals.failures ? `${totals.failures} run(s) need review` : 'No failed runs reported'}</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8">
|
||||
<StatTile label="Runs" value={totals.runs} />
|
||||
<StatTile label="Failures" value={totals.failures} />
|
||||
<StatTile label="Model cost" value={`$${totals.total_cost.toFixed(4)}`} sub={`${totals.provider_tokens.toLocaleString()} tokens`} />
|
||||
<StatTile label="Latency" value={`${totals.avg_latency_ms}ms`} sub="average" />
|
||||
<StatTile label="Fallbacks" value={totals.fallback_routes} />
|
||||
<StatTile label="Tool denies" value={totals.tool_denies} />
|
||||
<StatTile label="Action blocks" value={totals.action_blocks} />
|
||||
<StatTile label="H-signal flags" value={totals.hallucination_signals} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_330px]">
|
||||
<Card title="Harness signal map" right={<span className="text-xs text-slate-400">Live aggregate</span>}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{signalEntries.map(([harness, signal], index) => (
|
||||
<div key={harness} className="group rounded-xl border border-slate-200 bg-slate-50/70 p-4 transition hover:border-indigo-200 hover:bg-white hover:shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3"><span className="font-mono text-xs font-semibold text-indigo-600">{harness}</span><span className="text-[11px] text-slate-400">0{index + 1}</span></div>
|
||||
<div className="mt-3 text-sm font-medium leading-6 text-slate-700">{Object.entries(signal).map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`).join(' · ')}</div>
|
||||
</div>
|
||||
))}
|
||||
{signalEntries.length === 0 && <div className="text-sm text-slate-500">No harness signal aggregate has been recorded yet.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Audit chain" right={<StatusBadge value={data.audit_chain.last_decision ?? 'n/a'} />}>
|
||||
<div className="text-sm text-gray-600">records: {data.audit_chain.records} · head: <code className="text-xs">{data.audit_chain.head?.slice(0, 16) ?? '—'}…</code></div>
|
||||
<Card title="Audit anchor" right={<StatusBadge value={data.audit_chain.last_decision ?? 'pending'} />}>
|
||||
<div className="rounded-xl bg-slate-950 p-4 text-slate-100"><div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Current ledger head</div><div className="mt-2 break-all font-mono text-xs leading-5">{data.audit_chain.head ?? 'No audit head yet'}</div></div>
|
||||
<p className="mt-4 text-sm leading-6 text-slate-500">This value changes only when the governed ledger accepts a new event. Use the Governance view to inspect the decision path.</p>
|
||||
</Card>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
"class": "cloud",
|
||||
"requires_key": true,
|
||||
"key_env": "OPENAI_API_KEY"
|
||||
},
|
||||
"omniroute": {
|
||||
"model": "openai-compatible:auto/best-coding",
|
||||
"class": "gateway",
|
||||
"requires_key": true,
|
||||
"key_env": "CASAN_OPENAI_COMPATIBLE_API_KEY",
|
||||
"requires_preflight": true
|
||||
}
|
||||
},
|
||||
"role_bindings": {
|
||||
|
||||
@@ -284,17 +284,17 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id}
|
||||
|
||||
pclass = provider.get("class", "local")
|
||||
if pclass == "cloud" and provider.get("requires_key"):
|
||||
if provider.get("requires_key"):
|
||||
key_env = provider.get("key_env", "")
|
||||
if key_env and not os.environ.get(key_env):
|
||||
# Honest: do not silently downgrade a cloud request to a fake answer.
|
||||
return deterministic, {"mode": "deterministic", "reason": "cloud_key_unset", "provider": provider_id}
|
||||
return deterministic, {"mode": "deterministic", "reason": "provider_key_unset", "provider": provider_id}
|
||||
|
||||
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
|
||||
env = os.environ.copy()
|
||||
if pclass == "cloud":
|
||||
# Data policy 18.M.2: PII/secret must not reach a cloud model without the
|
||||
# C3 guard. Force the model-router preflight for any cloud-class provider.
|
||||
if pclass == "cloud" or provider.get("requires_preflight"):
|
||||
# Data policy 18.M.2: PII/secret must not reach a cloud or gateway model
|
||||
# without the C3 guard. Force the model-router preflight for either route.
|
||||
env["CASAN_PREFLIGHT"] = "1"
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
@@ -306,6 +306,22 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env,
|
||||
)
|
||||
# A locally available model is the safe operational fallback when a
|
||||
# configured cloud/gateway route is unavailable. The input is already
|
||||
# H4-scanned and the output still passes H4 below; we never fall back to
|
||||
# another network provider or bypass the router.
|
||||
fallback_from = ""
|
||||
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
|
||||
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
|
||||
fallback_model = fallback.get("model")
|
||||
if fallback_model and fallback.get("class", "local") == "local":
|
||||
fallback_from = provider_id
|
||||
r = subprocess.run(
|
||||
["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(),
|
||||
)
|
||||
if r.returncode == 0 and os.path.isfile(oj):
|
||||
provider_id, provider, model_spec, pclass = "local", fallback, fallback_model, "local"
|
||||
if r.returncode != 0 or not os.path.isfile(oj):
|
||||
return deterministic, {
|
||||
"mode": "deterministic",
|
||||
@@ -328,6 +344,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
"ollama": "ollama_local_real_tokens",
|
||||
"openai": "openai_api_real_tokens",
|
||||
"anthropic": "anthropic_api_real_tokens",
|
||||
"openai-compatible": "openai_compatible_api_real_tokens",
|
||||
}.get(_backend_of(model_spec), "model_real_tokens")
|
||||
return answer, {
|
||||
"mode": "model",
|
||||
@@ -338,6 +355,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
"input_tokens": int(out.get("input_tokens") or 0),
|
||||
"output_tokens": int(out.get("output_tokens") or 0),
|
||||
"cost_source": cost_source,
|
||||
"fallback_from": fallback_from or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -585,6 +603,76 @@ def verify_audit() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def history(args) -> int:
|
||||
"""Return a privacy-minimised, integrity-checked view of one actor's chats.
|
||||
|
||||
The Control Panel never reads the audit file itself. This harness command
|
||||
keeps tenant-path resolution, chain verification and field minimisation in
|
||||
the same trust boundary as chat writes. It exposes H4-scanned prompt
|
||||
previews and bounded governed-output previews only, never a raw user
|
||||
message or full audit record.
|
||||
"""
|
||||
if args.tenant and args.tenant != "default":
|
||||
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||
tenant_id = args.tenant or "default"
|
||||
records = []
|
||||
prev = GENESIS_HASH
|
||||
try:
|
||||
with open(audit_path(), encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
rest = {k: v for k, v in rec.items() if k != "record_hash"}
|
||||
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != rec.get("record_hash"):
|
||||
print(json.dumps({"ok": False, "reason": "chat_chain_broken"}, ensure_ascii=False))
|
||||
return 3
|
||||
prev = rec["record_hash"]
|
||||
if rec.get("tenant_id", "default") == tenant_id and rec.get("actor") == args.actor:
|
||||
records.append(rec)
|
||||
except OSError:
|
||||
records = []
|
||||
|
||||
conversations = {}
|
||||
for rec in records:
|
||||
chat_id = rec.get("chat_id") or "chat-default"
|
||||
current = conversations.get(chat_id)
|
||||
item = {
|
||||
"chat_id": chat_id,
|
||||
"title": (rec.get("safe_preview") or rec.get("answer_preview") or "Governed chat")[:80],
|
||||
"updated_at": rec.get("timestamp") or "",
|
||||
"turns": 1,
|
||||
"last_decision": rec.get("decision") or "UNKNOWN",
|
||||
"last_mode": rec.get("mode") or "READ_ONLY",
|
||||
}
|
||||
if current:
|
||||
item["turns"] = current["turns"] + 1
|
||||
if current.get("updated_at", "") > item["updated_at"]:
|
||||
item = current
|
||||
conversations[chat_id] = item
|
||||
|
||||
selected = records if not args.chat_id else [r for r in records if r.get("chat_id") == args.chat_id]
|
||||
selected = selected[-max(1, min(args.limit, 100)):]
|
||||
turns = [{
|
||||
"chat_id": rec.get("chat_id") or "chat-default",
|
||||
"turn_id": rec.get("turn_id") or "",
|
||||
"timestamp": rec.get("timestamp") or "",
|
||||
"mode": rec.get("mode") or "READ_ONLY",
|
||||
"risk": rec.get("risk") or "low",
|
||||
"decision": rec.get("decision") or "UNKNOWN",
|
||||
"prompt_preview": rec.get("safe_preview") or "",
|
||||
"answer_preview": (rec.get("answer_preview") or rec.get("answer") or "")[:180],
|
||||
"certified": rec.get("decision") == "ANSWERED",
|
||||
"audit_hash": rec.get("record_hash") or "",
|
||||
} for rec in selected]
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"conversations": sorted(conversations.values(), key=lambda item: item.get("updated_at", ""), reverse=True),
|
||||
"turns": turns,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -596,11 +684,18 @@ def main() -> int:
|
||||
askp.add_argument("--tenant", default="default")
|
||||
askp.add_argument("--stream", action="store_true")
|
||||
sub.add_parser("verify-audit")
|
||||
hp = sub.add_parser("history")
|
||||
hp.add_argument("--actor", required=True)
|
||||
hp.add_argument("--chat-id", default="")
|
||||
hp.add_argument("--tenant", default="default")
|
||||
hp.add_argument("--limit", type=int, default=50)
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "ask":
|
||||
return ask(args)
|
||||
if args.cmd == "verify-audit":
|
||||
return verify_audit()
|
||||
if args.cmd == "history":
|
||||
return history(args)
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
@@ -398,13 +398,13 @@ def _model_codegen_body(args):
|
||||
if not model_spec:
|
||||
return None, {"mode": "deterministic", "reason": "provider_unresolved"}
|
||||
pclass = provider.get("class", "local")
|
||||
if pclass == "cloud" and provider.get("requires_key"):
|
||||
if provider.get("requires_key"):
|
||||
key_env = provider.get("key_env", "")
|
||||
if key_env and not os.environ.get(key_env):
|
||||
return None, {"mode": "deterministic", "reason": "cloud_key_unset"}
|
||||
return None, {"mode": "deterministic", "reason": "provider_key_unset"}
|
||||
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
|
||||
env = os.environ.copy()
|
||||
if pclass == "cloud":
|
||||
if pclass == "cloud" or provider.get("requires_preflight"):
|
||||
env["CASAN_PREFLIGHT"] = "1"
|
||||
prompt = "\n".join([
|
||||
"You are CASAN's governed codegen assistant. Produce a SMALL Python draft",
|
||||
@@ -417,6 +417,16 @@ def _model_codegen_body(args):
|
||||
write_text(pf, prompt)
|
||||
r = subprocess.run(["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env)
|
||||
fallback_from = ""
|
||||
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
|
||||
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
|
||||
fallback_model = fallback.get("model")
|
||||
if fallback_model and fallback.get("class", "local") == "local":
|
||||
fallback_from = provider_id
|
||||
r = subprocess.run(["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
|
||||
if r.returncode == 0 and os.path.isfile(oj):
|
||||
provider_id, model_spec = "local", fallback_model
|
||||
if r.returncode != 0 or not os.path.isfile(oj):
|
||||
return None, {"mode": "deterministic", "reason": "model_unavailable"}
|
||||
try:
|
||||
@@ -432,6 +442,7 @@ def _model_codegen_body(args):
|
||||
"model": model_spec,
|
||||
"input_tokens": int(out.get("input_tokens") or 0),
|
||||
"output_tokens": int(out.get("output_tokens") or 0),
|
||||
"fallback_from": fallback_from or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -709,6 +720,13 @@ def verify_audit() -> int:
|
||||
return run_and_passthrough(["python3", READONLY, "verify-audit"])
|
||||
|
||||
|
||||
def history(args) -> int:
|
||||
command = ["python3", READONLY, "history", "--actor", args.actor, "--tenant", args.tenant, "--limit", str(args.limit)]
|
||||
if args.chat_id:
|
||||
command += ["--chat-id", args.chat_id]
|
||||
return run_and_passthrough(command)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -726,6 +744,12 @@ def main() -> int:
|
||||
askp.add_argument("--stream", action="store_true")
|
||||
askp.set_defaults(func=ask)
|
||||
sub.add_parser("verify-audit").set_defaults(func=lambda _args: verify_audit())
|
||||
hp = sub.add_parser("history")
|
||||
hp.add_argument("--actor", required=True)
|
||||
hp.add_argument("--chat-id", default="")
|
||||
hp.add_argument("--tenant", default="default")
|
||||
hp.add_argument("--limit", type=int, default=50)
|
||||
hp.set_defaults(func=history)
|
||||
args = ap.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# One entry point for the complete local CASAN lab. It keeps the infrastructure
|
||||
# lab and the authenticated Control Panel as separate Compose projects so their
|
||||
# lifecycle can be managed without port/network collisions.
|
||||
#
|
||||
# Usage: local-full.sh start|stop|status|verify|smoke|env
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/casan-paths.sh"
|
||||
ROOT="$CASAN_APP_ROOT"
|
||||
INFRA="$SCRIPT_DIR/infra-lab.sh"
|
||||
CP_COMPOSE="$ROOT/docker-compose.control-panel.local.yml"
|
||||
LOCAL_ENV="$ROOT/infra/local-prod/casan.local.env"
|
||||
TLS_DIR="$ROOT/tmp/control-panel-local/tls"
|
||||
CMD="${1:-status}"
|
||||
|
||||
cp_compose() {
|
||||
if [[ -f "$LOCAL_ENV" ]]; then
|
||||
docker compose --env-file "$LOCAL_ENV" -f "$CP_COMPOSE" "$@"
|
||||
else
|
||||
docker compose -f "$CP_COMPOSE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
need_docker() {
|
||||
command -v docker >/dev/null 2>&1 || { echo "CASAN_LOCAL_DOCKER_MISSING" >&2; exit 1; }
|
||||
docker compose version >/dev/null 2>&1 || { echo "CASAN_LOCAL_COMPOSE_MISSING" >&2; exit 1; }
|
||||
}
|
||||
|
||||
ensure_tls() {
|
||||
mkdir -p "$TLS_DIR"
|
||||
if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$TLS_DIR/tls.key" -out "$TLS_DIR/tls.crt" \
|
||||
-subj "/CN=localhost" -days 30 >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_url() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -k -fsS -m 3 "$url" >/dev/null 2>&1 && return 0
|
||||
sleep 1
|
||||
done
|
||||
echo "CASAN_LOCAL_WAIT_TIMEOUT url=$url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
start)
|
||||
need_docker
|
||||
bash "$INFRA" start
|
||||
ensure_tls
|
||||
cp_compose up -d --build
|
||||
wait_url "http://127.0.0.1:18082/healthz"
|
||||
# The unauthenticated console intentionally redirects, so test the OIDC IdP
|
||||
# here; `smoke` performs the complete logged-in browser flow.
|
||||
echo "CASAN_LOCAL_FULL_STARTED"
|
||||
echo "dashboard=http://127.0.0.1:18080 (basic auth: casan / casan)"
|
||||
echo "minio_console=http://127.0.0.1:19091 (casanadmin / casanadmin123)"
|
||||
echo "control_panel=https://localhost:18443 (self-signed TLS; mock OIDC login)"
|
||||
;;
|
||||
stop)
|
||||
need_docker
|
||||
cp_compose down --remove-orphans
|
||||
bash "$INFRA" stop
|
||||
echo "CASAN_LOCAL_FULL_STOPPED"
|
||||
;;
|
||||
status)
|
||||
need_docker
|
||||
echo "=== infrastructure ==="
|
||||
bash "$INFRA" status
|
||||
echo "=== control panel ==="
|
||||
cp_compose ps
|
||||
;;
|
||||
verify)
|
||||
need_docker
|
||||
bash "$INFRA" verify
|
||||
cp_compose ps
|
||||
wait_url "http://127.0.0.1:18082/healthz"
|
||||
echo "CASAN_LOCAL_FULL_VERIFY_PASS"
|
||||
;;
|
||||
smoke)
|
||||
need_docker
|
||||
bash "$ROOT/packages/casan-control-panel/scripts/local-prod-smoke.sh"
|
||||
;;
|
||||
env)
|
||||
bash "$INFRA" env
|
||||
printf '%s\n' \
|
||||
'# OmniRoute is opt-in. Replace <model-id> with an ID returned by /v1/models.' \
|
||||
'export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://127.0.0.1:20128/v1' \
|
||||
'export CASAN_OPENAI_COMPATIBLE_API_KEY="$OPENAI_API_KEY"' \
|
||||
'export CASAN_MODEL_PRIMARY=openai-compatible:<model-id>' \
|
||||
'export CASAN_CHAT_MODEL_MODE=model' \
|
||||
'export CASAN_CHAT_MODEL_PROVIDER=omniroute' \
|
||||
'export CASAN_PREFLIGHT=1'
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 start|stop|status|verify|smoke|env" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -23,6 +23,8 @@ Usage:
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _casan_app_root():
|
||||
@@ -43,6 +45,7 @@ import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
OLLAMA_HOST = "127.0.0.1:11434" # the only allowed ollama endpoint
|
||||
DOCKER_OLLAMA_HOST = "host.docker.internal:11434"
|
||||
ALLOWED_CLOUD = {"api.anthropic.com", "api.openai.com"}
|
||||
REPO_ROOT = _casan_app_root()
|
||||
PROVIDER_LOG = os.path.join(REPO_ROOT, ".specify/logs/level5/provider-usage.jsonl")
|
||||
@@ -131,11 +134,20 @@ def extract_verdict(role, text):
|
||||
return None, False
|
||||
|
||||
|
||||
def ollama_host_allowed(host: str) -> bool:
|
||||
return host == OLLAMA_HOST or (
|
||||
os.environ.get("CASAN_ALLOW_DOCKER_HOST_OLLAMA") == "1"
|
||||
and host == DOCKER_OLLAMA_HOST
|
||||
)
|
||||
|
||||
|
||||
def call_ollama(model_name, prompt, role):
|
||||
# SSRF guard: hard-pinned loopback endpoint, no env override of host.
|
||||
# SSRF guard: hard-pinned loopback endpoint by default. Docker Desktop has
|
||||
# one explicit, opt-in bridge to the Mac host's Ollama daemon; arbitrary
|
||||
# LAN, metadata and user-supplied hosts remain blocked.
|
||||
host = os.environ.get("CASAN_OLLAMA_HOST", OLLAMA_HOST)
|
||||
if host != OLLAMA_HOST:
|
||||
fail(f"endpoint_not_allowed ollama host={host} (only {OLLAMA_HOST})")
|
||||
if not ollama_host_allowed(host):
|
||||
fail(f"endpoint_not_allowed ollama host={host} (only {OLLAMA_HOST}; Docker bridge requires explicit opt-in)")
|
||||
digest_gate = os.path.join(os.path.dirname(__file__), "model-digest-check.sh")
|
||||
if os.path.isfile(digest_gate):
|
||||
check = subprocess.run(
|
||||
@@ -211,6 +223,73 @@ def call_openai(model_name, prompt, role):
|
||||
}
|
||||
|
||||
|
||||
def openai_compatible_url():
|
||||
"""Return a vetted OpenAI-compatible chat-completions endpoint.
|
||||
|
||||
This is opt-in, preserving the existing hard-pinned public OpenAI route.
|
||||
The default allowlist permits only local development hosts; another host
|
||||
must be explicitly approved by the operator.
|
||||
"""
|
||||
raw = os.environ.get("CASAN_OPENAI_COMPATIBLE_BASE_URL", "").strip()
|
||||
if not raw:
|
||||
fail("openai_compatible_base_url_unset")
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname \
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
fail("endpoint_not_allowed openai-compatible invalid_base_url")
|
||||
configured = os.environ.get("CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS", "")
|
||||
allowed_hosts = {host.strip().lower() for host in configured.split(",") if host.strip()} \
|
||||
or {"127.0.0.1", "localhost", "host.docker.internal"}
|
||||
host = parsed.hostname.lower()
|
||||
if host not in allowed_hosts:
|
||||
fail(f"endpoint_not_allowed openai-compatible host={host}")
|
||||
# HTTP is acceptable only for a loopback gateway or an explicitly named
|
||||
# private-LAN IP. Public HTTP and arbitrary SSRF targets remain blocked.
|
||||
if parsed.scheme == "http" and host not in {"127.0.0.1", "localhost", "host.docker.internal"}:
|
||||
try:
|
||||
is_private = ipaddress.ip_address(host).is_private
|
||||
except ValueError:
|
||||
is_private = False
|
||||
if not is_private:
|
||||
fail(f"endpoint_not_allowed openai-compatible insecure_host={host}")
|
||||
base_path = parsed.path.rstrip("/")
|
||||
if base_path not in {"", "/v1"}:
|
||||
fail("endpoint_not_allowed openai-compatible base_path_must_be_v1")
|
||||
return f"{parsed.scheme}://{parsed.netloc}{base_path}/chat/completions"
|
||||
|
||||
|
||||
def call_openai_compatible(model_name, prompt, role):
|
||||
"""Call an explicitly allowlisted OpenAI-compatible gateway (e.g. OmniRoute)."""
|
||||
key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||
if not key:
|
||||
fail("openai-compatible_backend_unavailable (API key unset)")
|
||||
body = {
|
||||
"model": model_name,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0 if role in ("classify", "judge") else 0.2,
|
||||
"max_tokens": 16 if role in ("classify", "judge") else 512,
|
||||
}
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
openai_compatible_url(), data=data,
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
|
||||
latency_ms = int((time.time() - t0) * 1000)
|
||||
text, input_tokens, output_tokens = parse_openai_payload(payload)
|
||||
return {
|
||||
"text": text,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
|
||||
def parse_openai_payload(payload):
|
||||
try:
|
||||
text = (payload["choices"][0]["message"]["content"] or "").strip()
|
||||
@@ -297,11 +376,18 @@ def main():
|
||||
model_spec = args.model
|
||||
if model_spec.startswith("ollama:"):
|
||||
backend, model_name = "ollama", model_spec[len("ollama:"):]
|
||||
elif model_spec.startswith(("anthropic:", "openai:")):
|
||||
elif model_spec.startswith(("anthropic:", "openai:", "openai-compatible:")):
|
||||
backend, model_name = model_spec.split(":", 1)
|
||||
key = os.environ.get("ANTHROPIC_API_KEY" if backend == "anthropic" else "OPENAI_API_KEY", "")
|
||||
if backend == "anthropic":
|
||||
key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
elif backend == "openai-compatible":
|
||||
key = os.environ.get("CASAN_OPENAI_COMPATIBLE_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
|
||||
else:
|
||||
key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if not key:
|
||||
# honest: cloud backend unavailable while key unset (do NOT fake)
|
||||
if backend == "openai-compatible":
|
||||
fail("openai-compatible_backend_unavailable (API key unset)")
|
||||
# Preserve the existing public-cloud unavailable contract.
|
||||
fail(f"cloud_backend_unavailable {backend} (API key unset)")
|
||||
else:
|
||||
fail(f"unknown_model_spec {model_spec}")
|
||||
@@ -311,6 +397,8 @@ def main():
|
||||
result = call_ollama(model_name, prompt, args.role)
|
||||
elif backend == "openai":
|
||||
result = call_openai(model_name, prompt, args.role)
|
||||
elif backend == "openai-compatible":
|
||||
result = call_openai_compatible(model_name, prompt, args.role)
|
||||
else: # anthropic
|
||||
result = call_anthropic(model_name, prompt, args.role)
|
||||
|
||||
@@ -343,6 +431,7 @@ def main():
|
||||
"ollama": "ollama_local_real_tokens",
|
||||
"openai": "openai_api_real_tokens",
|
||||
"anthropic": "anthropic_api_real_tokens",
|
||||
"openai-compatible": "openai_compatible_api_real_tokens",
|
||||
}.get(backend, f"{backend}_real_tokens")
|
||||
os.makedirs(os.path.dirname(PROVIDER_LOG), exist_ok=True)
|
||||
usage = {
|
||||
|
||||
@@ -106,7 +106,38 @@ assert d["decision"] == "DENIED"
|
||||
assert d["mode"] == "BLOCK"
|
||||
PY
|
||||
|
||||
# 5) Model OUTPUT still flows through the H4 output scan (no governance bypass):
|
||||
# 5) Gateway/cloud model failure falls back to the configured local model before
|
||||
# CASAN uses deterministic synthesis. This keeps an OmniRoute outage from taking
|
||||
# down an otherwise healthy local Ollama deployment.
|
||||
FALLBACK_CFG="$WORK/fallback-providers.json"
|
||||
cat > "$FALLBACK_CFG" <<'EOF'
|
||||
{"providers":{"gateway":{"model":"fake:gateway","class":"gateway","requires_preflight":true},"local":{"model":"fake:local","class":"local"}},"role_bindings":{"read_only":"gateway"}}
|
||||
EOF
|
||||
FALLBACK_STUB="$WORK/fallback-router.sh"
|
||||
cat > "$FALLBACK_STUB" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
OUT="$2"
|
||||
MODEL="${6:-}"
|
||||
if [[ "$MODEL" == "fake:gateway" ]]; then
|
||||
echo "gateway unavailable" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '{"text":"Local fallback answer.","input_tokens":31,"output_tokens":9}\n' > "$OUT"
|
||||
EOF
|
||||
chmod +x "$FALLBACK_STUB"
|
||||
CASAN_CHAT_MODEL_MODE=model CASAN_MODEL_PROVIDERS_FILE="$FALLBACK_CFG" CASAN_CHAT_MODEL_ROUTER="$FALLBACK_STUB" \
|
||||
python3 "$CHAT" ask --message "Summarize Plan 18 MVP-0 evidence" --actor alice --chat-id m5 > "$WORK/local-fallback.json"
|
||||
python3 - "$WORK/local-fallback.json" <<'PY' \
|
||||
&& pass "gateway failure falls back to local model" || fail "gateway-to-local fallback failed"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert d["success"] is True and d["synthesis"]["mode"] == "model", d
|
||||
assert d["synthesis"]["provider"] == "local", d["synthesis"]
|
||||
assert d["synthesis"]["fallback_from"] == "gateway", d["synthesis"]
|
||||
assert "Local fallback answer" in d["answer"], d["answer"]
|
||||
PY
|
||||
|
||||
# 6) Model OUTPUT still flows through the H4 output scan (no governance bypass):
|
||||
# a planted AWS key in the model text must be caught — the turn is DENIED
|
||||
# fail-closed and the raw secret never reaches the user.
|
||||
CASAN_CHAT_MODEL_MODE=model CASAN_CHAT_MODEL_ROUTER="$STUB" \
|
||||
|
||||
@@ -60,6 +60,27 @@ CASAN_OLLAMA_HOST="169.254.169.254:80" bash "$ROUTER" "$WORK/s.txt" "$WORK/s.jso
|
||||
RC=$?; set -e 2>/dev/null || true
|
||||
[[ "$RC" -ne 0 ]] && grep -q "endpoint_not_allowed" "$WORK/s.err" && pass "SSRF endpoint (metadata IP) rejected" || fail "SSRF endpoint not rejected (rc=$RC)"
|
||||
|
||||
# Docker Desktop may reach the operator's Mac-hosted Ollama only through one
|
||||
# explicit opt-in hostname. Other environment overrides must remain blocked.
|
||||
python - "$SCRIPTS/model-call.py" <<'PY'
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("mc", sys.argv[1])
|
||||
mc = importlib.util.module_from_spec(spec); spec.loader.exec_module(mc)
|
||||
assert mc.OLLAMA_HOST == "127.0.0.1:11434"
|
||||
assert mc.DOCKER_OLLAMA_HOST == "host.docker.internal:11434"
|
||||
assert mc.ollama_host_allowed(mc.OLLAMA_HOST) is True
|
||||
assert mc.ollama_host_allowed(mc.DOCKER_OLLAMA_HOST) is False
|
||||
os.environ["CASAN_OLLAMA_HOST"] = "169.254.169.254:80"
|
||||
os.environ["CASAN_ALLOW_DOCKER_HOST_OLLAMA"] = "1"
|
||||
assert mc.ollama_host_allowed(os.environ["CASAN_OLLAMA_HOST"]) is False
|
||||
assert mc.ollama_host_allowed(mc.DOCKER_OLLAMA_HOST) is True
|
||||
print("ok")
|
||||
PY
|
||||
[[ $? -eq 0 ]] && pass "Docker Ollama bridge is a single explicit host" || fail "Docker Ollama bridge guard coverage failed"
|
||||
|
||||
# 5: no API-key / secret pattern leaked into logs.
|
||||
if grep -rEq 'sk-[A-Za-z0-9]{20}|Authorization: Bearer|AKIA[0-9A-Z]{16}' "$CASAN_STATE_ROOT/logs" 2>/dev/null; then
|
||||
fail "a secret/key pattern appears in .specify/logs"
|
||||
@@ -98,7 +119,8 @@ else
|
||||
skip "cloud-unavailable test (ANTHROPIC_API_KEY is set)"
|
||||
fi
|
||||
|
||||
# 8: cloud response parsers use provider token usage and fail closed on malformed
|
||||
# 8: cloud and explicitly allowlisted OpenAI-compatible gateway response parsers
|
||||
# use provider token usage and fail closed on malformed
|
||||
# payloads. This is deterministic: urllib is monkeypatched, so no API key or
|
||||
# network call is needed.
|
||||
python - "$SCRIPTS/model-call.py" <<'PY'
|
||||
@@ -113,6 +135,8 @@ spec = importlib.util.spec_from_file_location("mc", sys.argv[1])
|
||||
mc = importlib.util.module_from_spec(spec); spec.loader.exec_module(mc)
|
||||
os.environ["OPENAI_API_KEY"] = "test-openai-key"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-anthropic-key"
|
||||
os.environ["CASAN_OPENAI_COMPATIBLE_API_KEY"] = "test-gateway-key"
|
||||
os.environ["CASAN_OPENAI_COMPATIBLE_BASE_URL"] = "http://127.0.0.1:20128/v1"
|
||||
|
||||
seen = []
|
||||
|
||||
@@ -128,7 +152,7 @@ class FakeResp:
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
seen.append((req.full_url, dict(req.header_items()), json.loads(req.data.decode())))
|
||||
if "openai.com" in req.full_url:
|
||||
if "openai.com" in req.full_url or "127.0.0.1:20128" in req.full_url:
|
||||
return FakeResp({
|
||||
"choices": [{"message": {"content": "SAFE"}}],
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 3},
|
||||
@@ -142,13 +166,25 @@ def fake_urlopen(req, timeout):
|
||||
|
||||
mc.urllib.request.urlopen = fake_urlopen
|
||||
op = mc.call_openai("gpt-test", "hello", "classify")
|
||||
gw = mc.call_openai_compatible("local-test", "hello", "classify")
|
||||
an = mc.call_anthropic("claude-test", "hello", "judge")
|
||||
assert op["text"] == "SAFE" and op["input_tokens"] == 11 and op["output_tokens"] == 3, op
|
||||
assert gw["text"] == "SAFE" and gw["input_tokens"] == 11 and gw["output_tokens"] == 3, gw
|
||||
assert an["text"] == "APPROVED" and an["input_tokens"] == 17 and an["output_tokens"] == 5, an
|
||||
assert seen[0][0] == "https://api.openai.com/v1/chat/completions", seen[0]
|
||||
assert seen[1][0] == "https://api.anthropic.com/v1/messages", seen[1]
|
||||
assert seen[1][0] == "http://127.0.0.1:20128/v1/chat/completions", seen[1]
|
||||
assert seen[2][0] == "https://api.anthropic.com/v1/messages", seen[2]
|
||||
assert seen[0][2]["temperature"] == 0 and seen[0][2]["max_tokens"] == 16, seen[0][2]
|
||||
assert "temperature" not in seen[1][2] and seen[1][2]["max_tokens"] == 16, seen[1][2]
|
||||
assert seen[1][2]["temperature"] == 0 and seen[1][2]["max_tokens"] == 16, seen[1][2]
|
||||
assert "temperature" not in seen[2][2] and seen[2][2]["max_tokens"] == 16, seen[2][2]
|
||||
|
||||
os.environ["CASAN_OPENAI_COMPATIBLE_BASE_URL"] = "http://169.254.169.254/v1"
|
||||
try:
|
||||
mc.openai_compatible_url()
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 2, exc.code
|
||||
else:
|
||||
raise AssertionError("gateway metadata endpoint was accepted")
|
||||
|
||||
for fn, bad in (
|
||||
(mc.parse_openai_payload, {"choices": [{"message": {"content": "SAFE"}}]}),
|
||||
@@ -163,7 +199,7 @@ for fn, bad in (
|
||||
raise AssertionError(f"{fn.__name__} accepted malformed provider payload")
|
||||
print("ok")
|
||||
PY
|
||||
[[ $? -eq 0 ]] && pass "cloud provider responses parse real usage and reject malformed payloads" || fail "cloud provider parser coverage failed"
|
||||
[[ $? -eq 0 ]] && pass "cloud/gateway provider responses parse real usage and reject malformed payloads" || fail "cloud/gateway provider parser coverage failed"
|
||||
|
||||
# 9: deliberate failing primary route -> fallback through the REAL router (not exit 9).
|
||||
if [[ "$TUNNEL_UP" -eq 1 ]]; then
|
||||
|
||||
Reference in New Issue
Block a user