diff --git a/.specify/logs/audit/security.jsonl b/.specify/logs/audit/security.jsonl index 17a8c08..e95ee10 100644 --- a/.specify/logs/audit/security.jsonl +++ b/.specify/logs/audit/security.jsonl @@ -146,3 +146,28 @@ {"timestamp":"2026-07-11T03:14:47Z","trace_id":"trace-1783739687-743","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"41787b8b4a629028ae972d8db1cf4ac491af928978e0469269655a140a41e20c","output_hash":"41787b8b4a629028ae972d8db1cf4ac491af928978e0469269655a140a41e20c"} {"timestamp":"2026-07-11T03:15:44Z","trace_id":"trace-1783739744-857","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"8367afe050a5086cac52162ee740dc4f68949e6901538d86f49802ad069e1e54","output_hash":"8367afe050a5086cac52162ee740dc4f68949e6901538d86f49802ad069e1e54"} {"timestamp":"2026-07-11T03:17:50Z","trace_id":"trace-1783739870-1068","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"744ebdf28580ca15b50dd647f69de9e8ae2288bd6267a2a792f4ca2816f856f9","output_hash":"744ebdf28580ca15b50dd647f69de9e8ae2288bd6267a2a792f4ca2816f856f9"} +{"timestamp":"2026-07-11T03:19:04Z","trace_id":"trace-1783739944-1756","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"e806101b2d4b3cec8f28fd45c77399235e8ca2b2c1bce040ab3ed4391aa35af9","output_hash":"e806101b2d4b3cec8f28fd45c77399235e8ca2b2c1bce040ab3ed4391aa35af9"} +{"timestamp":"2026-07-11T03:19:53Z","trace_id":"trace-1783739993-1916","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"5919bfe3362dac3ae263c409e8bde9ea28c19ccb60f66a577d87dbd1c79155a9","output_hash":"5919bfe3362dac3ae263c409e8bde9ea28c19ccb60f66a577d87dbd1c79155a9"} +{"timestamp":"2026-07-11T06:13:04Z","trace_id":"trace-1783750384-563","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"9c44c4098753d99eac0564d83466196e8b06676603197bb1989f59c8cea5cfee","output_hash":"9c44c4098753d99eac0564d83466196e8b06676603197bb1989f59c8cea5cfee"} +{"timestamp":"2026-07-11T06:13:40Z","trace_id":"trace-1783750420-1381","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"4e6dfed3949d4e086a6036a774eb32c32c3a7db28ff2df7964700d6ed1827c47","output_hash":"4e6dfed3949d4e086a6036a774eb32c32c3a7db28ff2df7964700d6ed1827c47"} +{"timestamp":"2026-07-11T06:14:13Z","trace_id":"trace-1783750453-1912","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"b4ab420209da88d5049e56e761583f033a6e3a06dc4b799449fc92742971d025","output_hash":"b4ab420209da88d5049e56e761583f033a6e3a06dc4b799449fc92742971d025"} +{"timestamp":"2026-07-11T06:14:21Z","trace_id":"trace-1783750461-2695","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"9262a241dadbea3d7fa31c1ad0ef8ee1b023eaf2c18031fa909af0e7ac22c471","output_hash":"9879dfe2a22879365df9fadaf0050b3ebeb772adf2f8f6b4890326da09768dd5"} +{"timestamp":"2026-07-11T06:14:26Z","trace_id":"trace-1783750466-3503","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"beb5005900229b0238f883d36f360b0d68ccf6c8eebbee0bbf41c113f2c217b1","output_hash":"beb5005900229b0238f883d36f360b0d68ccf6c8eebbee0bbf41c113f2c217b1"} +{"timestamp":"2026-07-11T06:14:34Z","trace_id":"trace-1783750474-4286","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"b4ab420209da88d5049e56e761583f033a6e3a06dc4b799449fc92742971d025","output_hash":"b4ab420209da88d5049e56e761583f033a6e3a06dc4b799449fc92742971d025"} +{"timestamp":"2026-07-11T06:15:13Z","trace_id":"trace-1783750513-5038","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80","output_hash":"d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80"} +{"timestamp":"2026-07-11T06:15:26Z","trace_id":"trace-1783750526-5538","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"2b1e37e270ab1f033240271acc714127b9ce72d72ad512441718b0e7d70233fe","output_hash":"2b1e37e270ab1f033240271acc714127b9ce72d72ad512441718b0e7d70233fe"} +{"timestamp":"2026-07-11T06:15:59Z","trace_id":"trace-1783750559-5676","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"92df10583db53f111389bf7f43092009479f63acbc77688b09931ab2f1826c94","output_hash":"92df10583db53f111389bf7f43092009479f63acbc77688b09931ab2f1826c94"} +{"timestamp":"2026-07-11T06:16:44Z","trace_id":"trace-1783750604-5875","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"d5305f9161e6a9924b631099d25bbfda83864774409abaea5cff8fe3b8e7078c","output_hash":"d5305f9161e6a9924b631099d25bbfda83864774409abaea5cff8fe3b8e7078c"} +{"timestamp":"2026-07-11T06:17:12Z","trace_id":"trace-1783750632-6501","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"3e5021bb069709d14b1204e08929822d9d6df26a85a88d180b667e5852c072de","output_hash":"3e5021bb069709d14b1204e08929822d9d6df26a85a88d180b667e5852c072de"} +{"timestamp":"2026-07-11T06:17:44Z","trace_id":"trace-1783750664-6635","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"85b5f71079fbab26d86b21be709e4cfecd9ffb412b8b140cb8d1a1d8661c8158","output_hash":"85b5f71079fbab26d86b21be709e4cfecd9ffb412b8b140cb8d1a1d8661c8158"} +{"timestamp":"2026-07-11T06:32:56Z","trace_id":"trace-1783751576-96","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca","output_hash":"2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca"} +{"timestamp":"2026-07-11T06:32:56Z","trace_id":"trace-1783751576-562","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5a1dcfbeb016bda513d7bcb284735be662d790cf22bdd64d3da54a2f6e362033","output_hash":"5a1dcfbeb016bda513d7bcb284735be662d790cf22bdd64d3da54a2f6e362033"} +{"timestamp":"2026-07-11T06:34:46Z","trace_id":"1f24d39b-bb2f-4f7b-be32-239649137e5a","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca","output_hash":"2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca"} +{"timestamp":"2026-07-11T06:34:50Z","trace_id":"4b439015-f562-4848-86c7-05278f67fdac","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"5a1dcfbeb016bda513d7bcb284735be662d790cf22bdd64d3da54a2f6e362033","output_hash":"5a1dcfbeb016bda513d7bcb284735be662d790cf22bdd64d3da54a2f6e362033"} +{"timestamp":"2026-07-11T06:36:54Z","trace_id":"trace-1783751814-1797","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80","output_hash":"d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80"} +{"timestamp":"2026-07-11T06:36:54Z","trace_id":"trace-1783751814-2278","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"b518b88dfbe9e555e583429e9454e4eab3cf5a6b7e7aeedae2d964794ad7751c","output_hash":"b518b88dfbe9e555e583429e9454e4eab3cf5a6b7e7aeedae2d964794ad7751c"} +{"timestamp":"2026-07-11T06:38:28Z","trace_id":"trace-1783751908-3155","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"610292a955a24da58cf6019b0299ea4453b87ea336ea12b37fc732702d9e22ce","output_hash":"610292a955a24da58cf6019b0299ea4453b87ea336ea12b37fc732702d9e22ce"} +{"timestamp":"2026-07-11T06:38:58Z","trace_id":"trace-1783751938-3335","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"62dd13b6753d71fd098d5929443afe324b0b8e965f43e97bf88d8eddcf797ecd","output_hash":"62dd13b6753d71fd098d5929443afe324b0b8e965f43e97bf88d8eddcf797ecd"} +{"timestamp":"2026-07-11T06:51:41Z","trace_id":"e22f32cf-615f-416c-8148-7d0c1d438dc1","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"b837d3eb2da4298e443aad246d4a8b13092af5332cd49c58a44c8ac9144db2b7","output_hash":"c56dc3b5c36c8da3652796b65c47b52f36d7a4ac0fe60dcbf5632471575369f7"} +{"timestamp":"2026-07-11T06:52:39Z","trace_id":"22155f0f-2095-4c9e-8624-4e476167d6ca","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"b837d3eb2da4298e443aad246d4a8b13092af5332cd49c58a44c8ac9144db2b7","output_hash":"c56dc3b5c36c8da3652796b65c47b52f36d7a4ac0fe60dcbf5632471575369f7"} +{"timestamp":"2026-07-11T06:53:49Z","trace_id":"d70ead8a-1f79-473b-aefe-62746cda2ac4","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"578eaad95e6441e9e3221d3117d11ef179a9c041b768a1de1ac9cc8b8f1320d7","output_hash":"578eaad95e6441e9e3221d3117d11ef179a9c041b768a1de1ac9cc8b8f1320d7"} diff --git a/.specify/logs/cost/metrics.jsonl b/.specify/logs/cost/metrics.jsonl index 27c4da7..688a0f0 100644 --- a/.specify/logs/cost/metrics.jsonl +++ b/.specify/logs/cost/metrics.jsonl @@ -29,3 +29,9 @@ {"timestamp": "2026-07-11T03:05:27Z", "trace_id": "832cc182-7bb8-4666-9bbc-c077a943e7f9", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 85641, "input_tokens": 127, "output_tokens": 620, "total_tokens": 747, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d2f690b77d00aa01559991486f7825c7a1ea402429c5638e8dbf2a40e7b6eb9a", "output_hash": "d94f7cb51d952fcaffb585e76c16542aba2f774d64c6d270eab32e7888eccc40"} {"timestamp": "2026-07-11T03:07:54Z", "trace_id": "3fc2c4a0-32ab-4be3-b12f-ca83a65869c7", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 85595, "input_tokens": 127, "output_tokens": 471, "total_tokens": 598, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d2f690b77d00aa01559991486f7825c7a1ea402429c5638e8dbf2a40e7b6eb9a", "output_hash": "fd6094f85e823a0be31d54bc5dbecf5e174275ad8b85ca2aa56f58658789e7a3"} {"timestamp": "2026-07-11T03:15:44Z", "trace_id": "d1f5f078-63dc-4f21-a4f7-c0ccab19bc3d", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 140071, "input_tokens": 2406, "output_tokens": 1400, "total_tokens": 3806, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "63073e9ea949155b9fc8db84dca22a3b8a635b11ab60e45b00c5914c842473fa", "output_hash": "8367afe050a5086cac52162ee740dc4f68949e6901538d86f49802ad069e1e54"} +{"timestamp": "2026-07-11T03:19:53Z", "trace_id": "ecd5d171-5967-45b6-8823-4ec1648d75a9", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 123701, "input_tokens": 2128, "output_tokens": 1400, "total_tokens": 3528, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "744ebdf28580ca15b50dd647f69de9e8ae2288bd6267a2a792f4ca2816f856f9", "output_hash": "5919bfe3362dac3ae263c409e8bde9ea28c19ccb60f66a577d87dbd1c79155a9"} +{"timestamp": "2026-07-11T06:15:59Z", "trace_id": "d5928317-30a8-434f-83f7-c344d777695e", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 46051, "input_tokens": 126, "output_tokens": 213, "total_tokens": 339, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80", "output_hash": "92df10583db53f111389bf7f43092009479f63acbc77688b09931ab2f1826c94"} +{"timestamp": "2026-07-11T06:17:44Z", "trace_id": "41f2f9c0-3bc4-4e03-af65-b1aff6bdb593", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 60390, "input_tokens": 499, "output_tokens": 454, "total_tokens": 953, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d5305f9161e6a9924b631099d25bbfda83864774409abaea5cff8fe3b8e7078c", "output_hash": "85b5f71079fbab26d86b21be709e4cfecd9ffb412b8b140cb8d1a1d8661c8158"} +{"timestamp": "2026-07-11T06:32:57Z", "trace_id": "07fbb2de-5044-4cad-90de-ac87387d74e8", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 647, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca", "output_hash": "a96d7829c4e1453035563d1e36c0e1cc5e8aabcf18ecb79e0934a54c53df579f"} +{"timestamp": "2026-07-11T06:34:56Z", "trace_id": "092ded09-3bbf-4469-9438-f4fe5b9e3d61", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 9940, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca", "output_hash": "a96d7829c4e1453035563d1e36c0e1cc5e8aabcf18ecb79e0934a54c53df579f"} +{"timestamp": "2026-07-11T06:38:58Z", "trace_id": "8cb7ab8b-c84e-425c-a761-673888ebce72", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 124131, "input_tokens": 2435, "output_tokens": 1400, "total_tokens": 3835, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80", "output_hash": "62dd13b6753d71fd098d5929443afe324b0b8e965f43e97bf88d8eddcf797ecd"} diff --git a/.specify/logs/level5/provider-usage.jsonl b/.specify/logs/level5/provider-usage.jsonl index 4a06af7..7d5d131 100644 --- a/.specify/logs/level5/provider-usage.jsonl +++ b/.specify/logs/level5/provider-usage.jsonl @@ -44,3 +44,9 @@ {"timestamp": "2026-07-11T03:04:43Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 127, "output_tokens": 620, "total_tokens": 747, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 41710, "status": "success"} {"timestamp": "2026-07-11T03:07:06Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 127, "output_tokens": 471, "total_tokens": 598, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 37299, "status": "success"} {"timestamp": "2026-07-11T03:14:47Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2406, "output_tokens": 1400, "total_tokens": 3806, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 82969, "status": "success"} +{"timestamp": "2026-07-11T03:19:04Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2128, "output_tokens": 1400, "total_tokens": 3528, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 74123, "status": "success"} +{"timestamp": "2026-07-11T06:13:11Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "classify", "role": "classify", "input_tokens": 215, "output_tokens": 2, "total_tokens": 217, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 6582, "status": "success"} +{"timestamp": "2026-07-11T06:13:40Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 625, "output_tokens": 533, "total_tokens": 1158, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 28228, "status": "success"} +{"timestamp": "2026-07-11T06:15:26Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 126, "output_tokens": 213, "total_tokens": 339, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 12167, "status": "success"} +{"timestamp": "2026-07-11T06:17:12Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 499, "output_tokens": 454, "total_tokens": 953, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 27972, "status": "success"} +{"timestamp": "2026-07-11T06:38:28Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2435, "output_tokens": 1400, "total_tokens": 3835, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 93084, "status": "success"} diff --git a/AGENTS.md b/AGENTS.md index 0eed0a0..7614481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,13 +205,13 @@ export default { ### Controller Rules: -- **Location:** Must be in `backend/src/[module-name]/[module-name].controller.ts`. +- **Location:** Must be in `apps/okr/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`. +- **Location:** Must be in `apps/okr/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. @@ -220,14 +220,14 @@ export default { ### Prisma Schema Rules: -- **Single source of truth:** `backend/prisma/schema.prisma` defines ALL tables. +- **Single source of truth:** `apps/okr/backend/prisma/schema.prisma` defines ALL tables. - **Migrations:** Use `npx prisma migrate dev --name ` — never edit migration files manually. - **Types:** Always use Prisma-generated types (`Prisma.ObjectiveCreateInput`, `Prisma.KeyResultUpdateInput`). ### Module Structure (OKR Domain): ``` -backend/src/ +apps/okr/backend/src/ ├── auth/ # JWT login, refresh token endpoints ├── users/ # User CRUD (Admin/Manager only) ├── objectives/ # Objective CRUD, filtering by quarter/owner/status @@ -238,12 +238,12 @@ backend/src/ ### Database Seed Management: - **CRITICAL:** After completing backend code with schema changes, **ALWAYS** update the seed file. -- **Seed file location:** `backend/prisma/seed.ts` +- **Seed file location:** `apps/okr/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 +// apps/okr/backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; @@ -324,7 +324,7 @@ main() ### Routing Rules (React Router DOM v6): ```tsx -// frontend/src/App.tsx — route structure +// apps/okr/frontend/src/App.tsx — route structure } /> }> @@ -340,14 +340,14 @@ main() ### 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/` +- **Route-level pages:** `apps/okr/frontend/src/pages/` (Login, Dashboard, OKRDetail, CreateObjective, KeyResultDetail) +- **Layout components:** `apps/okr/frontend/src/components/layout/` (Sidebar, Header, AppLayout) +- **Reusable UI components:** `apps/okr/frontend/src/components/ui/` (Button, ProgressBar, Badge, Table) +- **Custom hooks:** `apps/okr/frontend/src/hooks/` (useAuth, useObjectives, useKeyResults) +- **API client:** `apps/okr/frontend/src/lib/api.ts` — all Axios calls go here +- **Query client config:** `apps/okr/frontend/src/lib/queryClient.ts` +- **Zod schemas:** `apps/okr/frontend/src/schemas/` +- **TypeScript interfaces:** `apps/okr/frontend/src/types/` ### Layout Construction Rules: @@ -389,7 +389,7 @@ main() ### API Call Rules: -- All functions that make network requests must be in `frontend/src/lib/api.ts`. +- All functions that make network requests must be in `apps/okr/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. @@ -397,7 +397,7 @@ main() ### Form Rules (React Hook Form + Zod): ```typescript -// frontend/src/schemas/objective.schema.ts +// apps/okr/frontend/src/schemas/objective.schema.ts import { z } from 'zod'; export const createObjectiveSchema = z.object({ @@ -455,7 +455,7 @@ const { register, handleSubmit, formState: { errors } } = useForm` — never edit migration files manually. - **Types:** Always use Prisma-generated types (`Prisma.ObjectiveCreateInput`, `Prisma.KeyResultUpdateInput`). ### Module Structure (OKR Domain): ``` -backend/src/ +apps/okr/backend/src/ ├── auth/ # JWT login, refresh token endpoints ├── users/ # User CRUD (Admin/Manager only) ├── objectives/ # Objective CRUD, filtering by quarter/owner/status @@ -238,12 +238,12 @@ backend/src/ ### Database Seed Management: - **CRITICAL:** After completing backend code with schema changes, **ALWAYS** update the seed file. -- **Seed file location:** `backend/prisma/seed.ts` +- **Seed file location:** `apps/okr/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 +// apps/okr/backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; @@ -324,7 +324,7 @@ main() ### Routing Rules (React Router DOM v6): ```tsx -// frontend/src/App.tsx — route structure +// apps/okr/frontend/src/App.tsx — route structure } /> }> @@ -340,14 +340,14 @@ main() ### 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/` +- **Route-level pages:** `apps/okr/frontend/src/pages/` (Login, Dashboard, OKRDetail, CreateObjective, KeyResultDetail) +- **Layout components:** `apps/okr/frontend/src/components/layout/` (Sidebar, Header, AppLayout) +- **Reusable UI components:** `apps/okr/frontend/src/components/ui/` (Button, ProgressBar, Badge, Table) +- **Custom hooks:** `apps/okr/frontend/src/hooks/` (useAuth, useObjectives, useKeyResults) +- **API client:** `apps/okr/frontend/src/lib/api.ts` — all Axios calls go here +- **Query client config:** `apps/okr/frontend/src/lib/queryClient.ts` +- **Zod schemas:** `apps/okr/frontend/src/schemas/` +- **TypeScript interfaces:** `apps/okr/frontend/src/types/` ### Layout Construction Rules: @@ -389,7 +389,7 @@ main() ### API Call Rules: -- All functions that make network requests must be in `frontend/src/lib/api.ts`. +- All functions that make network requests must be in `apps/okr/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. @@ -397,7 +397,7 @@ main() ### Form Rules (React Hook Form + Zod): ```typescript -// frontend/src/schemas/objective.schema.ts +// apps/okr/frontend/src/schemas/objective.schema.ts import { z } from 'zod'; export const createObjectiveSchema = z.object({ @@ -455,7 +455,7 @@ const { register, handleSubmit, formState: { errors } } = useForm FAIL=0 SKIP=`. Any FAIL fails the job (exit 1). The gate is diff --git a/docs/packaging/DOMAIN_PACK_GUIDE.md b/docs/packaging/DOMAIN_PACK_GUIDE.md index a3119cc..8410dc7 100644 --- a/docs/packaging/DOMAIN_PACK_GUIDE.md +++ b/docs/packaging/DOMAIN_PACK_GUIDE.md @@ -30,7 +30,7 @@ apps//domain/ a run against it; similarity 1.0 = no drift. Real drift detection is proven separately. - **corpus/** — red-team attack vectors (scored for H4 recall) + a benign corpus (bounds the false-positive rate). Domain-specific injections make the H4 score meaningful. -- **traceability-map.json** — `{"FR-01": {"name": "...", "code": ["backend/src/...", {"file":"...","symbols":["Foo"],"lines":[12]}], "tests": ["..."]}}`. Symbol/line refs are +- **traceability-map.json** — `{"FR-01": {"name": "...", "code": ["apps/okr/backend/src/...", {"file":"...","symbols":["Foo"],"lines":[12]}], "tests": ["..."]}}`. Symbol/line refs are optional but tighten the gate. - **domain-pack.yaml** — documents the above + optional threshold overrides. diff --git a/docs/technical_architecture.md b/docs/technical_architecture.md index 6029477..6b9aa8c 100644 --- a/docs/technical_architecture.md +++ b/docs/technical_architecture.md @@ -79,7 +79,7 @@ ``` okr-web/ -├── backend/ # NestJS API service +├── apps/okr/backend/ # NestJS API service │ ├── src/ │ │ ├── main.ts # Bootstrap, Swagger, global pipes │ │ ├── app.module.ts # Root module @@ -97,7 +97,7 @@ okr-web/ │ ├── Dockerfile │ └── package.json │ -├── frontend/ # React + Vite SPA +├── apps/okr/frontend/ # React + Vite SPA │ ├── src/ │ │ ├── main.tsx # React root │ │ ├── App.tsx # Router setup + error boundary @@ -285,7 +285,7 @@ exec "$@" The seed script uses `upsert` (Prisma's `createOrUpdate`) keyed on stable identifiers (email for users, slug for objectives). Running the seed twice produces no duplicates: ```typescript -// backend/prisma/seed.ts +// apps/okr/backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; @@ -616,7 +616,7 @@ TanStack Query's `onError` global callback handles API error toasts without cras - **Unit tests:** Services tested in isolation with Prisma mocked via `jest.mock`. - **Integration tests:** `@nestjs/testing` spins up full NestJS app with SQLite in-memory database override. -- Test files co-located under `backend/test/`. +- Test files co-located under `apps/okr/backend/test/`. ### Frontend diff --git a/package-lock.json b/package-lock.json index 7ab10ad..71d22a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "ainative-okr-casan5", "workspaces": [ - "backend", - "frontend", + "apps/okr/backend", + "apps/okr/frontend", "packages/casan-control-panel/backend", "packages/casan-control-panel/frontend" ] }, - "backend": { + "apps/okr/backend": { "name": "@ainative-okr/backend", "version": "1.0.0", "dependencies": { @@ -39,7 +39,7 @@ "typescript": "^5.8.3" } }, - "frontend": { + "apps/okr/frontend": { "name": "@ainative-okr/frontend", "version": "1.0.0", "dependencies": { @@ -77,11 +77,11 @@ "license": "MIT" }, "node_modules/@ainative-okr/backend": { - "resolved": "backend", + "resolved": "apps/okr/backend", "link": true }, "node_modules/@ainative-okr/frontend": { - "resolved": "frontend", + "resolved": "apps/okr/frontend", "link": true }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index cc15cb3..2a13e5c 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "ainative-okr-casan5", "private": true, "workspaces": [ - "backend", - "frontend", + "apps/okr/backend", + "apps/okr/frontend", "packages/casan-control-panel/backend", "packages/casan-control-panel/frontend" ], "scripts": { - "build": "npm run build -w backend && npm run build -w frontend", - "test": "npm test -w backend && npm test -w frontend", + "build": "npm run build -w @ainative-okr/backend && npm run build -w @ainative-okr/frontend", + "test": "npm test -w @ainative-okr/backend && npm test -w @ainative-okr/frontend", "console:api": "npm run dev -w @casan/control-panel-backend", "console:ui": "npm run dev -w @casan/control-panel-frontend", "console:build": "npm run build -w @casan/control-panel-backend && npm run build -w @casan/control-panel-frontend", diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md index e0d3989..bb029f1 100644 --- a/packages/casan-control-panel/README.md +++ b/packages/casan-control-panel/README.md @@ -38,6 +38,17 @@ Settings management: permission; calls `rbac-check.py` before `control-plane-settings.py set`. - `POST /api/v1/settings/rollback` — governed rollback through the same core CLI. +Goal workspace context: + +- `GET /api/v1/goals/projects` — lists active project IDs and context roots from the + harness-owned project registry after RBAC filtering; browser-supplied paths are never accepted. +- `POST /api/v1/goals` requires `{ goal, projectId }`. H1 resolves the registry again, + produces a size-limited redacted manifest/snapshot, and gives the exact same snapshot to + local and cloud models. Account-model CLIs remain inside an empty temporary sandbox. +- Goals requesting workspace side effects create a tenant-scoped + `goal.workspace.execute` approval proposal and finish as `requires_approval`; this flow + does not write source files or execute a coding action. + Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`, `x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed. diff --git a/packages/casan-control-panel/backend/src/approvals/approvals.service.ts b/packages/casan-control-panel/backend/src/approvals/approvals.service.ts index 872bf45..55df3e8 100644 --- a/packages/casan-control-panel/backend/src/approvals/approvals.service.ts +++ b/packages/casan-control-panel/backend/src/approvals/approvals.service.ts @@ -77,8 +77,8 @@ function stableJson(value: unknown): string { export class ApprovalsService { list(actor: SettingsActor, status = 'pending') { this.requireRbac(actor, 'monitoring', 'read'); - const res = runFile('python3', [INBOX_CLI, 'list', '--status', status]); - return { ...parseJson>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit() }; + const res = runFile('python3', [INBOX_CLI, 'list', '--status', status], this.tenantEnv(actor)); + return { ...parseJson>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit(actor) }; } submit(input: ApprovalSubmit, actor: SettingsActor) { @@ -109,8 +109,8 @@ export class ApprovalsService { JSON.stringify(input.payload ?? {}), ]; if (input.sensitive) args.push('--sensitive'); - const res = runFile('python3', args); - return { proposal: parseJson>(res.stdout, {}), audit_verify: this.verifyAudit() }; + const res = runFile('python3', args, this.tenantEnv(actor)); + return { proposal: parseJson>(res.stdout, {}), audit_verify: this.verifyAudit(actor) }; } decide(input: ApprovalDecision, actor: SettingsActor) { @@ -119,7 +119,7 @@ export class ApprovalsService { } this.requireRbac(actor, 'approval', 'grant'); try { - const pending = this.findProposal(input.id); + const pending = this.findProposal(input.id, actor); this.verifyApprovalIdentity(input, actor, pending); const res = runFile('python3', [ INBOX_CLI, @@ -132,10 +132,10 @@ export class ApprovalsService { actor.actor, '--reason', input.reason, - ]); + ], this.tenantEnv(actor)); const proposal = parseJson>(res.stdout, {}); const applied = input.decision === 'approve' ? this.applyApprovedProposal(proposal, actor) : null; - return { proposal, applied, audit_verify: this.verifyAudit() }; + return { proposal, applied, audit_verify: this.verifyAudit(actor) }; } catch (err: any) { if (err instanceof ForbiddenException) throw err; if (Number(err.status) === 3 || Number(err.status) === 1) { @@ -145,8 +145,8 @@ export class ApprovalsService { } } - private findProposal(id: string) { - const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all']); + private findProposal(id: string, actor: SettingsActor) { + const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all'], this.tenantEnv(actor)); const store = parseJson>(res.stdout, { proposals: [] }); const proposal = (store.proposals ?? []).find((p: Record) => p.id === id); if (!proposal) throw new ForbiddenException(`APPROVAL_DECIDE_DENY unknown_id ${id}`); @@ -199,7 +199,7 @@ export class ApprovalsService { `approved:${proposal.id}:${proposal.decision_reason ?? ''}`, '--approval', `inbox:${proposal.id}:${actor.actor}`, - ]); + ], this.tenantEnv(actor)); return parseJson>(res.stdout, {}); } catch (err: any) { if (Number(err.status) === 2 || Number(err.status) === 3) { @@ -234,9 +234,13 @@ export class ApprovalsService { } } - private verifyAudit() { + private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv { + return { CASAN_TENANT_ID: actor.tenant || 'default' }; + } + + private verifyAudit(actor: SettingsActor) { try { - const res = runFile('python3', [INBOX_CLI, 'verify-audit']); + const res = runFile('python3', [INBOX_CLI, 'verify-audit'], this.tenantEnv(actor)); return { ok: true, output: res.stdout }; } catch (err: any) { return { ok: false, output: err.stderr || err.stdout || err.message }; diff --git a/packages/casan-control-panel/backend/src/goals/goals.controller.ts b/packages/casan-control-panel/backend/src/goals/goals.controller.ts index 10e5e1f..984ee9d 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.controller.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.controller.ts @@ -12,6 +12,11 @@ export class GoalsController { return ok(await this.service.start(body, actorFromHeaders(headers))); } + @Get('projects') + projects(@Headers() headers: Record) { + return ok(this.service.projects(actorFromHeaders(headers))); + } + @Get() list(@Headers() headers: Record, @Query('limit') limit?: string) { return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20)); diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts index c819d3e..25abd58 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'; import { execFileSync, spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; @@ -8,6 +8,14 @@ import type { SettingsActor } from '../settings/settings.service.js'; export interface GoalStartInput { goal: string; + projectId: string; +} + +export interface GoalProject { + project_id: string; + domain: string; + domain_root: string; + context_roots: string[]; } export interface GoalStage { @@ -23,7 +31,7 @@ export interface GoalJob { id: string; trace_id: string; goal: string; - status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed'; + status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval'; actor: string; tenant: string; project: string; @@ -42,6 +50,9 @@ export interface GoalJob { audit_hash?: string; local_usage?: Record; cloud_usage?: Record; + workspace?: GoalProject; + context_manifest?: { files: number; characters: number; truncated: boolean; path?: string }; + approval?: { id: string; status: string; action: string }; } interface ModelConnection { @@ -67,6 +78,7 @@ const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py'); const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py'); const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py'); +const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json'); function parseJson(value: string): T | null { try { @@ -86,7 +98,8 @@ export class GoalsService { private readonly startWindows = new Map(); async start(input: GoalStartInput, actor: SettingsActor): Promise { - this.requireRead(actor); + const workspace = this.resolveProject(String(input.projectId ?? '')); + this.requireRead(actor, workspace.project_id); const goal = String(input.goal ?? '').trim(); if (goal.length < 10 || goal.length > 8000) { throw new BadRequestException('GOAL_LENGTH_INVALID'); @@ -115,7 +128,8 @@ export class GoalsService { status: 'queued', actor: actor.actor, tenant: actor.tenant, - project: actor.project, + project: workspace.project_id, + workspace, created_at: timestamp, updated_at: timestamp, local_provider: local?.id || 'local-policy', @@ -156,24 +170,39 @@ export class GoalsService { return job; } + projects(actor: SettingsActor): { count: number; projects: GoalProject[] } { + const projects = this.registeredProjects().filter((project) => { + try { + this.requireRead(actor, project.project_id); + return true; + } catch { + return false; + } + }); + return { count: projects.length, projects }; + } + get(id: string, actor: SettingsActor): GoalJob { - this.requireRead(actor); if (!/^[a-f0-9-]{36}$/.test(id)) throw new NotFoundException('GOAL_NOT_FOUND'); const path = this.jobPath(actor.tenant, id); if (!existsSync(path)) throw new NotFoundException('GOAL_NOT_FOUND'); const job = parseJson(readFileSync(path, 'utf8')); if (!job || job.tenant !== actor.tenant) throw new NotFoundException('GOAL_NOT_FOUND'); + this.requireRead(actor, job.project); return job; } list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } { - this.requireRead(actor); + this.requireRead(actor, actor.project); const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant)); if (!existsSync(directory)) return { count: 0, goals: [] }; const goals = readdirSync(directory) .filter((name) => /^[a-f0-9-]{36}\.json$/.test(name)) .map((name) => parseJson(readFileSync(join(directory, name), 'utf8'))) .filter((job): job is GoalJob => Boolean(job && job.tenant === actor.tenant)) + .filter((job) => { + try { this.requireRead(actor, job.project); return true; } catch { return false; } + }) .sort((left, right) => right.created_at.localeCompare(left.created_at)); return { count: goals.length, goals: goals.slice(0, Math.max(1, Math.min(limit, 100))) }; } @@ -182,6 +211,34 @@ export class GoalsService { return join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(tenant), `${id}.json`); } + private registeredProjects(): GoalProject[] { + const parsed = parseJson<{ projects?: Array> }>(readFileSync(PROJECT_REGISTRY, 'utf8')); + const root = realpathSync(APP_ROOT); + return (parsed?.projects ?? []).filter((entry) => entry.status === 'active').map((entry) => { + const projectId = String(entry.project_id ?? ''); + const domainRoot = String(entry.domain_root ?? ''); + const rawRoots = Array.isArray(entry.context_roots) ? entry.context_roots.map(String) : [domainRoot]; + if (!/^[A-Za-z0-9._-]+$/.test(projectId) || !domainRoot || rawRoots.length === 0) { + throw new InternalServerErrorException('GOAL_PROJECT_REGISTRY_INVALID'); + } + const contextRoots = rawRoots.map((relative) => { + const absolute = realpathSync(join(APP_ROOT, relative)); + if (!(absolute === root || absolute.startsWith(`${root}/`)) || !statSync(absolute).isDirectory() && !statSync(absolute).isFile()) { + throw new InternalServerErrorException('GOAL_PROJECT_CONTEXT_ROOT_DENIED'); + } + return relative; + }); + return { project_id: projectId, domain: String(entry.domain ?? projectId), domain_root: domainRoot, context_roots: contextRoots }; + }); + } + + private resolveProject(projectId: string): GoalProject { + if (!projectId) throw new BadRequestException('GOAL_PROJECT_REQUIRED'); + const project = this.registeredProjects().find((entry) => entry.project_id === projectId); + if (!project) throw new BadRequestException('GOAL_PROJECT_NOT_ALLOWED'); + return project; + } + private connections(actor: SettingsActor): ModelConnection[] { const payload = this.runPython(CONNECTIONS_CLI, ['list'], { CASAN_TENANT_ID: actor.tenant || 'default' }); const parsed = parseJson(payload); @@ -259,10 +316,10 @@ export class GoalsService { } } - private requireRead(actor: SettingsActor): void { + private requireRead(actor: SettingsActor, targetProject: string): void { try { execFileSync('python3', [RBAC_CLI, 'check', '--role', actor.role, '--resource', 'monitoring', '--action', 'read', - '--role-project', actor.project, '--target-project', actor.project, + '--role-project', actor.project, '--target-project', targetProject, '--role-tenant', actor.tenant, '--target-tenant', actor.tenant], { cwd: APP_ROOT, env: process.env, diff --git a/packages/casan-control-panel/backend/src/settings/settings.service.ts b/packages/casan-control-panel/backend/src/settings/settings.service.ts index 820d75a..06fac2e 100644 --- a/packages/casan-control-panel/backend/src/settings/settings.service.ts +++ b/packages/casan-control-panel/backend/src/settings/settings.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { APP_ROOT } from '../common/app-root.js'; @@ -60,10 +60,11 @@ function parseJson(raw: string, fallback: T): T { export class SettingsService { list(actor: SettingsActor) { this.requireRbac(actor, 'read', false); - const policy = parseJson>(runPython(CP_CLI, ['list-policy']).stdout, {}); - const settings = parseJson>(runPython(CP_CLI, ['get-all']).stdout, {}); - const audit = parseJson(runPython(CP_CLI, ['get-audit']).stdout, []); - const auditVerify = this.verifyAudit(); + const tenantEnv = this.tenantEnv(actor); + const policy = parseJson>(runPython(CP_CLI, ['list-policy'], tenantEnv).stdout, {}); + const settings = parseJson>(runPython(CP_CLI, ['get-all'], tenantEnv).stdout, {}); + const audit = parseJson(runPython(CP_CLI, ['get-audit'], tenantEnv).stdout, []); + const auditVerify = this.verifyAudit(actor); return { actor, @@ -83,7 +84,7 @@ export class SettingsService { if (!input.key || input.value === undefined || !input.reason) { throw new ForbiddenException('SETTINGS_DENY key/value/reason required'); } - const sensitive = this.isSensitive(input.key); + const sensitive = this.isSensitive(input.key, actor); this.requireRbac(actor, 'write', sensitive); try { const res = runPython(CP_CLI, [ @@ -96,11 +97,12 @@ export class SettingsService { input.reason, '--approval', input.approval ?? '', - ]); - return { key: input.key, setting: parseJson>(res.stdout, {}), audit_verify: this.verifyAudit() }; + ], this.tenantEnv(actor)); + return { key: input.key, setting: parseJson>(res.stdout, {}), audit_verify: this.verifyAudit(actor) }; } catch (err: any) { if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'APPROVAL_REQUIRED'); if (Number(err.status) === 2) throw new ForbiddenException(err.stderr || 'SETTING_NOT_ALLOWED'); + if (Number(err.status) === 5) throw new BadRequestException(err.stderr || 'SETTING_VALIDATION_ERROR'); throw new InternalServerErrorException(err.stderr || err.message); } } @@ -109,19 +111,19 @@ export class SettingsService { if (!input.key || !input.reason) { throw new ForbiddenException('SETTINGS_DENY key/reason required'); } - const sensitive = this.isSensitive(input.key); + const sensitive = this.isSensitive(input.key, actor); this.requireRbac(actor, 'write', sensitive); try { - const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason]); - return { key: input.key, setting: parseJson>(res.stdout, {}), audit_verify: this.verifyAudit() }; + const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason], this.tenantEnv(actor)); + return { key: input.key, setting: parseJson>(res.stdout, {}), audit_verify: this.verifyAudit(actor) }; } catch (err: any) { if (Number(err.status) === 4) throw new ForbiddenException(err.stderr || 'NO_PRIOR_VERSION'); throw new InternalServerErrorException(err.stderr || err.message); } } - private isSensitive(key: string): boolean { - const policy = parseJson>(runPython(CP_CLI, ['list-policy']).stdout, {}); + private isSensitive(key: string, actor: SettingsActor): boolean { + const policy = parseJson>(runPython(CP_CLI, ['list-policy'], this.tenantEnv(actor)).stdout, {}); return Boolean(policy[key]?.securitySensitive); } @@ -164,9 +166,13 @@ export class SettingsService { return runPython(RBAC_CLI, args); } - private verifyAudit() { + private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv { + return { CASAN_TENANT_ID: actor.tenant }; + } + + private verifyAudit(actor: SettingsActor) { try { - const res = runPython(CP_CLI, ['verify-audit']); + const res = runPython(CP_CLI, ['verify-audit'], this.tenantEnv(actor)); return { ok: true, output: res.stdout }; } catch (err: any) { return { ok: false, output: err.stderr || err.stdout || err.message }; diff --git a/packages/casan-control-panel/backend/test/approvals.test.ts b/packages/casan-control-panel/backend/test/approvals.test.ts index ddddfdb..f9bcf94 100644 --- a/packages/casan-control-panel/backend/test/approvals.test.ts +++ b/packages/casan-control-panel/backend/test/approvals.test.ts @@ -16,8 +16,10 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi const prevStore = process.env.CASAN_CP_STORE_FILE; const prevKeyDir = process.env.CASAN_CP_KEY_DIR; const prevPub = process.env.CASAN_CP_PUB; + const prevTenantRoot = process.env.CASAN_TENANT_STATE_ROOT; const work = mkdtempSync(join(tmpdir(), 'cp-approval-')); - process.env.CASAN_APPROVAL_INBOX_FILE = join(work, 'approval-inbox.json'); + process.env.CASAN_TENANT_STATE_ROOT = work; + process.env.CASAN_APPROVAL_INBOX_FILE = join(work, 'default', 'approvals', 'approval-inbox.json'); process.env.CASAN_CP_STORE_FILE = join(work, 'settings.json'); process.env.CASAN_CP_KEY_DIR = join(work, 'keys'); process.env.CASAN_CP_PUB = join(work, 'cp.pub'); @@ -34,6 +36,8 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi else process.env.CASAN_CP_KEY_DIR = prevKeyDir; if (prevPub === undefined) delete process.env.CASAN_CP_PUB; else process.env.CASAN_CP_PUB = prevPub; + if (prevTenantRoot === undefined) delete process.env.CASAN_TENANT_STATE_ROOT; + else process.env.CASAN_TENANT_STATE_ROOT = prevTenantRoot; } } diff --git a/packages/casan-control-panel/backend/test/goals.test.ts b/packages/casan-control-panel/backend/test/goals.test.ts new file mode 100644 index 0000000..37d2481 --- /dev/null +++ b/packages/casan-control-panel/backend/test/goals.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { BadRequestException } from '@nestjs/common'; +import { GoalsService } from '../src/goals/goals.service.js'; + +const root = join(import.meta.dirname, '..', '..', '..', '..'); +const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' }; + +test('goal project selector exposes only active allowlisted registry entries', () => { + const result = new GoalsService().projects(admin); + assert.ok(result.projects.some((project) => project.project_id === 'AINative_OKR_CASAN4')); + assert.ok(result.projects.every((project) => project.context_roots.every((contextRoot) => !contextRoot.startsWith('/')))); + assert.ok(result.projects.every((project) => project.project_id !== 'CASAN_DEMO_PROJECT_A')); +}); + +test('goal start rejects project ids outside the server registry before model routing', async () => { + await assert.rejects( + new GoalsService().start({ goal: 'Review the current application architecture safely', projectId: '../../tmp' }, admin), + BadRequestException, + ); +}); + +test('H1 creates a bounded manifest and routes workspace side effects to approval without writing source', () => { + const stateRoot = mkdtempSync(join(tmpdir(), 'casan-goal-context-')); + const tenantRoot = join(stateRoot, 'tenants'); + const jobDirectory = join(stateRoot, 'state', 'goals', 'goal-test'); + mkdirSync(jobDirectory, { recursive: true }); + const jobPath = join(jobDirectory, '11111111-1111-1111-1111-111111111111.json'); + writeFileSync(jobPath, JSON.stringify({ + id: '11111111-1111-1111-1111-111111111111', + trace_id: '11111111-1111-1111-1111-111111111111', + goal: 'Hãy sửa code OKR để thêm một nút mới ngay bây giờ', + status: 'queued', actor: 'goal-admin', tenant: 'goal-test', project: 'AINative_OKR_CASAN4', + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + local_provider: 'local-policy', local_model: 'unused', cloud_provider: 'unused', cloud_model: 'unused', + stages: [ + { id: 'local-worker', status: 'queued', detail: 'Waiting', provider: '', model: '' }, + { id: 'cloud-reviewer', status: 'queued', detail: 'Waiting', provider: '', model: '' }, + ], + })); + execFileSync('python3', [join(root, 'packages/casan-harness/scripts/bash/goal-orchestrator.py'), '--job-file', jobPath], { + cwd: root, + env: { ...process.env, CASAN_STATE_ROOT: stateRoot, CASAN_TENANT_ID: 'goal-test', CASAN_TENANT_STATE_ROOT: tenantRoot }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 60_000, + }); + const job = JSON.parse(readFileSync(jobPath, 'utf8')) as { status: string; context_manifest: { files: number; characters: number }; approval: { action: string } }; + assert.equal(job.status, 'requires_approval'); + assert.equal(job.approval.action, 'goal.workspace.execute'); + assert.ok(job.context_manifest.files > 0); + assert.ok(job.context_manifest.files <= 16); + assert.ok(job.context_manifest.characters <= 7000); +}); diff --git a/packages/casan-control-panel/backend/test/settings.test.ts b/packages/casan-control-panel/backend/test/settings.test.ts index fb4020f..6db0401 100644 --- a/packages/casan-control-panel/backend/test/settings.test.ts +++ b/packages/casan-control-panel/backend/test/settings.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { SettingsService } from '../src/settings/settings.service.js'; const viewer = { actor: 'viewer-1', role: 'viewer', project: 'default', tenant: 'default' }; @@ -41,6 +41,17 @@ test('viewer cannot write settings', () => { }); }); +test('invalid setting value is rejected before it reaches the store', () => { + withTempStore((storeFile) => { + const svc = new SettingsService(); + assert.throws( + () => svc.set({ key: 'loop.max_steps', value: 0, reason: 'invalid lower bound' }, admin), + BadRequestException, + ); + assert.equal(existsSync(storeFile), false); + }); +}); + test('org-admin writes and rolls back through governed store with audit', () => { withTempStore((storeFile) => { const svc = new SettingsService(); diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index 30379b5..3b5a9c4 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -273,7 +273,7 @@ export interface GoalJob { id: string; trace_id: string; goal: string; - status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed'; + status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval'; actor: string; tenant: string; project: string; @@ -290,6 +290,16 @@ export interface GoalJob { result?: string; error?: string; audit_hash?: string; + workspace?: GoalProject; + context_manifest?: { files: number; characters: number; truncated: boolean; path?: string }; + approval?: { id: string; status: string; action: string }; +} + +export interface GoalProject { + project_id: string; + domain: string; + domain_root: string; + context_roots: string[]; } export interface ChatReplay { @@ -336,9 +346,28 @@ export interface SettingsState { can_write_sensitive: boolean; can_rollback: boolean; }; - policy: Record; + policy: Record; settings: Record; - audit: any[]; + audit: Array<{ + seq: number; + key: string; + action: 'set' | 'rollback'; + value: unknown; + prevValue: unknown; + actor: string; + reason: string; + at: string; + hash: string; + }>; audit_verify: { ok: boolean; output: string }; } @@ -450,8 +479,10 @@ export const api = { getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)), startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) => post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)), - startGoal: (actor: SettingsActor, goal: string) => - post('goals', { goal }, actorHeaders(actor)), + goalProjects: (actor: SettingsActor) => + getWithHeaders<{ count: number; projects: GoalProject[] }>('goals/projects', actorHeaders(actor)), + startGoal: (actor: SettingsActor, goal: string, projectId: string) => + post('goals', { goal, projectId }, actorHeaders(actor)), goal: (actor: SettingsActor, id: string) => getWithHeaders(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)), goals: (actor: SettingsActor, limit = 20) => diff --git a/packages/casan-control-panel/frontend/src/pages/Goals.tsx b/packages/casan-control-panel/frontend/src/pages/Goals.tsx index b9a263a..165454d 100644 --- a/packages/casan-control-panel/frontend/src/pages/Goals.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Goals.tsx @@ -1,14 +1,14 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { api, type GoalJob, type SettingsActor } from '../lib/api'; import { Card, StatusBadge } from '../components/ui/Card'; import { TraceExplorer } from '../components/trace/TraceExplorer'; import { MarkdownText } from '../components/ui/MarkdownText'; import { ModelInteractionDiagram } from '../components/goals/ModelInteractionDiagram'; -const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' }; -const TERMINAL = new Set(['completed', 'degraded', 'failed']); +const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'org-admin', project: 'default', tenant: 'default' }; +const TERMINAL = new Set(['completed', 'degraded', 'failed', 'requires_approval']); function errorMessage(error: unknown): string { if (typeof error === 'object' && error !== null) { @@ -45,10 +45,14 @@ function WorkerCard({ title, subtitle, status, detail, provider, model }: { export function Goals() { const [goal, setGoal] = useState(''); + const [projectId, setProjectId] = useState(''); const [actor] = useState(DEFAULT_ACTOR); const [searchParams, setSearchParams] = useSearchParams(); const selectedId = searchParams.get('id') ?? ''; const queryClient = useQueryClient(); + const projectsQuery = useQuery({ queryKey: ['goal-projects', actor], queryFn: () => api.goalProjects(actor) }); + const projects = projectsQuery.data?.projects ?? []; + const effectiveProject = projectId || projects[0]?.project_id || ''; const listQuery = useQuery({ queryKey: ['goals', actor], queryFn: () => api.goals(actor, 20) }); const selectedQuery = useQuery({ @@ -61,7 +65,7 @@ export function Goals() { }, }); const start = useMutation({ - mutationFn: () => api.startGoal(actor, goal.trim()), + mutationFn: () => api.startGoal({ ...actor, project: effectiveProject }, goal.trim(), effectiveProject), onSuccess: (job) => { setSearchParams({ id: job.id }); setGoal(''); @@ -85,6 +89,15 @@ export function Goals() { +