Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81eade2e1 | ||
|
|
098ed13705 | ||
|
|
b739ef1fb7 | ||
|
|
4031593ea8 | ||
|
|
510b75c0bf | ||
|
|
ac72ca6f52 | ||
|
|
932ffe5203 | ||
|
|
d27eb900b1 | ||
|
|
fa8aa59b29 | ||
|
|
64df1e784c | ||
|
|
c970cb2419 | ||
|
|
7cce5e99c7 | ||
|
|
003650df24 | ||
|
|
41beccab7e | ||
|
|
5e86421100 | ||
|
|
7efc92feac | ||
|
|
695bd24d1d | ||
|
|
80cf1d73ce | ||
|
|
23f0fba6ec | ||
|
|
5bf7375b50 | ||
|
|
b3645beea0 | ||
|
|
f137703f1b | ||
|
|
500899121b | ||
|
|
ddc54e739a | ||
|
|
10c510d9ef | ||
|
|
0e6d0ecd66 | ||
|
|
70e2ee684f | ||
|
|
a3fc9b3af0 | ||
|
|
563abd8481 |
229
CLAUDE.md
229
CLAUDE.md
@ -1,71 +1,81 @@
|
|||||||
# KAOS — Mastermind
|
# KAOS — Mastermind
|
||||||
|
|
||||||
**Verzija:** 0.3.0
|
**Verzija:** 0.6.0
|
||||||
**Poslednje ažuriranje:** 2026-02-20
|
**Ažurirano:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Učesnici
|
||||||
|
|
||||||
|
| Uloga | Ko | Šta radi |
|
||||||
|
|-------|----|----------|
|
||||||
|
| Klijent | Korisnik | Prijavi problem/zahtev kroz dashboard formu |
|
||||||
|
| Planer | Claude u chatu | Piše taskove, odgovara na pitanja agenata |
|
||||||
|
| Operater | Nenad | Odobrava, pregleda, kontroliše. Šef. |
|
||||||
|
| Agent | Claude Code na serveru | Izvršava taskove (čist kontekst po tasku) |
|
||||||
|
|
||||||
|
Komunikacija: kroz fajlove u TASKS/ (sync Windows ↔ Server).
|
||||||
|
Planer i Agent nikad direktno. → detalji: `TASKS/Workflow-Spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard (http://10.0.0.1:8080)
|
||||||
|
|
||||||
|
| Tab | Funkcija |
|
||||||
|
|-----|----------|
|
||||||
|
| Kanban | Board sa kolonama, drag & drop, workflow dugmad |
|
||||||
|
| Dokumenti | Pregled svih .md fajlova (goldmark renderovanje) |
|
||||||
|
| Konzola | Terminal — 2 paralelne claude sesije |
|
||||||
|
| Prijava | Klijent mod (forma) + Operater mod (chat sa claude CLI) |
|
||||||
|
|
||||||
|
### Workflow dugmad na karticama
|
||||||
|
|
||||||
|
| Stanje | Dugme |
|
||||||
|
|--------|-------|
|
||||||
|
| backlog, zavisnosti ❌ | 🔒 Blokiran |
|
||||||
|
| backlog, zavisnosti ✅ | 👁 Pregledaj → ✅ Odobri |
|
||||||
|
| ready | ▶ Pusti (pokreni agenta) |
|
||||||
|
| active | ⚙️ Radi |
|
||||||
|
| review, pitanje | 💬 Odgovori → ▶ Nastavi |
|
||||||
|
| review, završen | 👁 Pregledaj → ✅ Odobri / ↩ Vrati |
|
||||||
|
| done | 📊 Izveštaj |
|
||||||
|
|
||||||
|
### Pokretanje agenta
|
||||||
|
|
||||||
|
"Pusti ▶" pokrene NOV `claude` proces sa čistim kontekstom:
|
||||||
|
```bash
|
||||||
|
claude --permission-mode bypassPermissions -p "Pročitaj CLAUDE.md i radi task TASKS/ready/T{XX}.md"
|
||||||
|
```
|
||||||
|
Svaki task = zasebna sesija. Nema istorije iz prethodnih taskova.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kad te pokrenu
|
## Kad te pokrenu
|
||||||
|
|
||||||
Ti razgovaraš sa operaterom. Operater je tvoj šef — on odlučuje šta se radi.
|
1. Pogledaj `TASKS/ready/` i `TASKS/review/`
|
||||||
|
2. Pokaži operateru šta ćeš da radiš — **čekaj odobrenje**
|
||||||
|
3. Izvršavaj po pravilima agenta (`agents/*/CLAUDE.md`)
|
||||||
|
|
||||||
### Tok rada
|
### Kad završiš task
|
||||||
|
|
||||||
1. Operater kaže "radi" ili "T01" ili "nastavi"
|
- Build/test/vet moraju proći
|
||||||
2. Pogledaj `TASKS/ready/` — ima li task spreman za rad
|
- Commit: `T{XX}: Opis na srpskom`
|
||||||
3. Pogledaj `TASKS/review/` — ima li task sa dopunjenim odgovorima
|
- Push + tag (semver) + push tags
|
||||||
4. Pokaži operateru šta ćeš da radiš — **čekaj odobrenje**
|
- Izveštaj u `TASKS/reports/T{XX}-report.md`
|
||||||
5. Kad dobiješ ok — izvršavaj
|
- Premesti task u `review/`
|
||||||
|
|
||||||
### Kad izvršavaš task
|
|
||||||
|
|
||||||
1. Premesti fajl iz `ready/` u `active/`
|
|
||||||
2. Pročitaj `agents/coder/CLAUDE.md` — pravila kodiranja
|
|
||||||
3. Kod piši u `code/` folderu
|
|
||||||
4. Ako imaš pitanje:
|
|
||||||
- Zapiši pitanje u task fajl pod `## Pitanja`
|
|
||||||
- Premesti fajl iz `active/` u `review/`
|
|
||||||
- Reci operateru "imam pitanje, čekam odgovor u fajlu"
|
|
||||||
- **STANI — ne radi dalje dok operater ne kaže "nastavi"**
|
|
||||||
5. Kad nastaviš:
|
|
||||||
- Pročitaj odgovor u task fajlu
|
|
||||||
- Premesti fajl iz `review/` u `active/`
|
|
||||||
- Nastavi rad
|
|
||||||
6. Kad završiš:
|
|
||||||
- Svi testovi moraju proći
|
|
||||||
- Build mora proći
|
|
||||||
- Commituj: `T{XX}: Opis na srpskom`
|
|
||||||
- Push: `git push origin main`
|
|
||||||
- Tag: `git tag v0.1.{PATCH}` (patch = redni broj završenog taska)
|
|
||||||
- Push tag: `git push origin --tags`
|
|
||||||
- Napiši izveštaj u `TASKS/reports/T{XX}-report.md`
|
|
||||||
- Premesti task fajl iz `active/` u `review/`
|
|
||||||
- Reci operateru "gotovo, čeka pregled"
|
|
||||||
|
|
||||||
### NIKAD
|
### NIKAD
|
||||||
|
|
||||||
- Ne radi bez odobrenja operatera
|
- Ne radi bez odobrenja operatera
|
||||||
- Ne pretpostavljaj šta operater želi
|
|
||||||
- Ne preskoči "čekaj odobrenje"
|
- Ne preskoči "čekaj odobrenje"
|
||||||
- Ne radi na tasku koji nije u `ready/` ili `review/`
|
- Ne radi na tasku koji nije u `ready/` ili `review/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task folderi
|
## Ko šta sme da premesti
|
||||||
|
|
||||||
```
|
| Iz → U | Operater | Agent |
|
||||||
TASKS/
|
|---------|----------|-------|
|
||||||
├── backlog/ ← novi taskovi (piše planer, čeka odobrenje operatera)
|
|
||||||
├── ready/ ← odobreni za rad (operater premesti iz backlog/)
|
|
||||||
├── active/ ← u izradi (agent premesti iz ready/)
|
|
||||||
├── review/ ← čeka pregled (agent ima pitanje ili završio)
|
|
||||||
├── done/ ← završeno i odobreno (operater premesti iz review/)
|
|
||||||
└── reports/ ← izveštaji izvršenih taskova
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ko šta sme da premesti
|
|
||||||
|
|
||||||
| Iz → U | Operater (dashboard) | Agent (CLI) |
|
|
||||||
|---------|---------------------|-------------|
|
|
||||||
| backlog → ready | ✅ | ❌ |
|
| backlog → ready | ✅ | ❌ |
|
||||||
| ready → backlog | ✅ | ❌ |
|
| ready → backlog | ✅ | ❌ |
|
||||||
| ready → active | ❌ | ✅ |
|
| ready → active | ❌ | ✅ |
|
||||||
@ -74,93 +84,68 @@ TASKS/
|
|||||||
| review → ready | ✅ | ❌ |
|
| review → ready | ✅ | ❌ |
|
||||||
| done → bilo gde | ❌ | ❌ |
|
| done → bilo gde | ❌ | ❌ |
|
||||||
|
|
||||||
|
Server validira. Nedozvoljen potez → 403.
|
||||||
|
Deployer jedini KREIRA taskove u backlog/ (greške iz logova).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Struktura projekta
|
## Struktura
|
||||||
|
|
||||||
```
|
```
|
||||||
/root/projects/KAOS/
|
/opt/kaos/
|
||||||
│
|
├── CLAUDE.md ← OVO
|
||||||
├── CLAUDE.md ← OVO — mastermind (v0.3.0)
|
├── agents/*/CLAUDE.md ← pravila po agentu
|
||||||
├── README.md
|
├── TASKS/ ← backlog/ready/active/review/done/reports/
|
||||||
│
|
├── code/ ← Go + HTMX (jedan binary, nema npm)
|
||||||
├── agents/ ← specijalizovani agenti
|
├── logs/ ← persistent logovi (planirano)
|
||||||
│ ├── triage/CLAUDE.md
|
├── documentation/ ← eksterna dokumentacija
|
||||||
│ ├── task-manager/CLAUDE.md
|
└── templates/new-project/ ← template za nove projekte
|
||||||
│ ├── coder/CLAUDE.md
|
|
||||||
│ ├── frontend/CLAUDE.md
|
|
||||||
│ ├── checker/CLAUDE.md
|
|
||||||
│ ├── reporter/CLAUDE.md
|
|
||||||
│ ├── docs/CLAUDE.md
|
|
||||||
│ └── deployer/CLAUDE.md
|
|
||||||
│
|
|
||||||
├── documentation/ ← eksterna dokumentacija (tuđe)
|
|
||||||
│
|
|
||||||
├── TASKS/ ← taskovi po stanju
|
|
||||||
│ ├── backlog/
|
|
||||||
│ ├── ready/
|
|
||||||
│ ├── active/
|
|
||||||
│ ├── review/
|
|
||||||
│ ├── done/
|
|
||||||
│ ├── reports/
|
|
||||||
│ ├── MASTER-STATUS.md
|
|
||||||
│ └── Implementation-Tasks.md
|
|
||||||
│
|
|
||||||
└── code/ ← Go kod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Go 1.22+ · Gin · HTMX + Sortable.js (nema npm) · goldmark (markdown) · Gitea (localhost:3000) · Hetzner (10.0.0.1) · Claude Code CLI (Pro licenca)
|
||||||
|
|
||||||
|
Projekat: `/opt/kaos/` (vlasnik: kaos korisnik)
|
||||||
|
Server radi kao root, agenti se pokreću kao `kaos` korisnik.
|
||||||
|
Operater mod u Prijavi koristi `claude` CLI (Pro licenca), ne API.
|
||||||
|
API ključ (ANTHROPIC_API_KEY) se čuva za budući direktni API mod.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Agent registar
|
## Agent registar
|
||||||
|
|
||||||
| Agent | Folder | Model | Verzija |
|
| Agent | Model | Verzija | Detalji |
|
||||||
|-------|--------|-------|---------|
|
|-------|-------|---------|---------|
|
||||||
| Triage | agents/triage/ | Haiku | 0.1.0 |
|
| Triage | Haiku | 0.1.0 | `agents/triage/CLAUDE.md` |
|
||||||
| Task Manager | agents/task-manager/ | Sonnet/Haiku | 0.1.0 |
|
| Task Manager | Sonnet/Haiku | 0.1.0 | `agents/task-manager/CLAUDE.md` |
|
||||||
| Coder | agents/coder/ | Sonnet/Opus | 0.2.0 |
|
| Coder | Sonnet/Opus | 0.2.0 | `agents/coder/CLAUDE.md` |
|
||||||
| Frontend | agents/frontend/ | Sonnet | 0.2.0 |
|
| Frontend | Sonnet | 0.2.0 | `agents/frontend/CLAUDE.md` |
|
||||||
| Checker | agents/checker/ | Haiku/Opus | 0.1.0 |
|
| Checker | Haiku/Opus | 0.1.0 | `agents/checker/CLAUDE.md` |
|
||||||
| Reporter | agents/reporter/ | Haiku | 0.1.0 |
|
| Reporter | Haiku | 0.1.0 | `agents/reporter/CLAUDE.md` |
|
||||||
| Docs | agents/docs/ | Haiku | 0.1.0 |
|
| Docs | Haiku | 0.1.0 | `agents/docs/CLAUDE.md` |
|
||||||
| Deployer | agents/deployer/ | Haiku | 0.1.0 |
|
| Deployer | Haiku/Sonnet | 0.2.0 | `agents/deployer/CLAUDE.md` |
|
||||||
|
|
||||||
|
Pravilo: najjeftiniji model koji može da uradi posao.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Model selekcija
|
## Verzionisanje
|
||||||
|
|
||||||
Pravilo: uvek najjeftiniji model koji može da uradi posao.
|
Semver. Patch = task, Minor = milestone, Major = breaking change.
|
||||||
|
Git: commit → push → tag → push tags. Format: `T{XX}: Opis`
|
||||||
| Zadatak | Agent | Model | Cena/M tokena |
|
Timeout: 30 min default (KAOS_TIMEOUT u .env).
|
||||||
|---------|-------|-------|---------------|
|
|
||||||
| Klasifikacija prijave | triage | Haiku | $0.25/$1.25 |
|
|
||||||
| Generisanje taska | task-manager | Sonnet | $3/$15 |
|
|
||||||
| Kodiranje | coder | Sonnet | $3/$15 |
|
|
||||||
| Kompleksno kodiranje | coder | Opus | $15/$75 |
|
|
||||||
| Frontend | frontend | Sonnet | $3/$15 |
|
|
||||||
| Build + Test | checker | Haiku | $0.25/$1.25 |
|
|
||||||
| Code review | checker | Opus | $15/$75 |
|
|
||||||
| Izveštaj | reporter | Haiku | $0.25/$1.25 |
|
|
||||||
| Dokumentacija | docs | Haiku | $0.25/$1.25 |
|
|
||||||
| Deploy | deployer | Haiku | $0.25/$1.25 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pristup
|
## Reference
|
||||||
|
|
||||||
| Folder | Čita | Piše |
|
| Šta | Gde |
|
||||||
|--------|------|------|
|
|-----|-----|
|
||||||
| agents/ | ✅ | ❌ |
|
| Status svih taskova + bugovi | `TASKS/MASTER-STATUS.md` |
|
||||||
| TASKS/ | ✅ | ✅ (status, premesti fajlove) |
|
| Kompletan workflow | `TASKS/Workflow-Spec.md` |
|
||||||
| documentation/ | ✅ | ❌ |
|
| Multi-agent arhitektura | `TASKS/Multi-Agent-Spec.md` |
|
||||||
| code/ | ✅ | ✅ (kad izvršava task) |
|
| Format izveštaja | `TASKS/reports/T01-report.md` (primer) |
|
||||||
|
| Template za novi projekat | `templates/new-project/` |
|
||||||
---
|
|
||||||
|
|
||||||
## Verzionisanje CLAUDE.md fajlova
|
|
||||||
|
|
||||||
Format: `Major.Minor.Patch`
|
|
||||||
- Patch (0.1.1) — sitne ispravke
|
|
||||||
- Minor (0.2.0) — nova pravila, novi korak
|
|
||||||
- Major (1.0.0) — fundamentalna promena
|
|
||||||
|
|
||||||
Kad se promeni CLAUDE.md → podigne verzija → ažurira Agent registar.
|
|
||||||
|
|||||||
68
README.md
68
README.md
@ -1,9 +1,9 @@
|
|||||||
# KAOS — AI-Supervised Development System
|
# KAOS — AI-Supervised Development System
|
||||||
|
|
||||||
**Verzija:** 0.1.0
|
**Verzija:** 0.3.0
|
||||||
**Status:** Pokretanje
|
**Status:** Aktivan razvoj
|
||||||
**Autor:** DAL d.o.o.
|
**Autor:** DAL d.o.o.
|
||||||
**Poslednje ažuriranje:** 2026-02-20
|
**Poslednje azuriranje:** 2026-02-21
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -101,13 +101,23 @@ Deploy ili dorada
|
|||||||
│ ├── regulations/
|
│ ├── regulations/
|
||||||
│ └── third-party/
|
│ └── third-party/
|
||||||
│
|
│
|
||||||
└── TASKS/ ← taskovi, specifikacije, izveštaji
|
├── docs/ ← dokumentacija
|
||||||
├── MASTER-STATUS.md
|
│ ├── SPEC.md
|
||||||
├── Architecture.md
|
│ ├── ARCHITECTURE.md
|
||||||
├── Workflow-Spec.md
|
│ └── SETUP.md
|
||||||
├── Supervisor-Spec.md
|
│
|
||||||
├── Multi-Agent-Spec.md
|
├── code/ ← Go kod (server, testovi)
|
||||||
├── Implementation-Tasks.md
|
│ ├── cmd/kaos-server/
|
||||||
|
│ ├── internal/server/
|
||||||
|
│ ├── internal/supervisor/
|
||||||
|
│ └── web/templates/
|
||||||
|
│
|
||||||
|
└── TASKS/ ← taskovi po stanju
|
||||||
|
├── backlog/
|
||||||
|
├── ready/
|
||||||
|
├── active/
|
||||||
|
├── review/
|
||||||
|
├── done/
|
||||||
└── reports/
|
└── reports/
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -115,30 +125,26 @@ Deploy ili dorada
|
|||||||
|
|
||||||
## Verzije
|
## Verzije
|
||||||
|
|
||||||
### v0.1 — Osnova (TRENUTNO)
|
### v0.1 — Osnova
|
||||||
- Mastermind + agenti definisani u CLAUDE.md fajlovima
|
- Mastermind + agenti definisani u CLAUDE.md fajlovima
|
||||||
- Supervisor: ručno pokretanje (`kaos-supervisor run T01`)
|
- Supervisor: rucno pokretanje (`kaos-supervisor run T01`)
|
||||||
- Checker: build + test + vet (deterministički)
|
- Checker: build + test + vet
|
||||||
- Izveštaji: markdown u TASKS/reports/
|
- Izvestaji: markdown u TASKS/reports/
|
||||||
- Git: direktno na main
|
|
||||||
- Nema baze, nema frontend-a, nema AI trijaže
|
|
||||||
|
|
||||||
### v0.2 — Automatizacija (planirano)
|
### v0.2 — Dashboard
|
||||||
- Supervisor daemon ili watch folder
|
- Web dashboard sa Kanban board-om
|
||||||
- AI trijaža prijava
|
- Drag & drop premestanje taskova
|
||||||
- AI compliance provere (modul, pravila, konvencije)
|
- SSE real-time update
|
||||||
- Staging → main branch strategija
|
- Pretraga, dokumenti, prijava taskova
|
||||||
- Auto-retry za flaky testove
|
|
||||||
- Notifikacije (konfigurabilan kanal)
|
|
||||||
|
|
||||||
### v0.3 — Kompletni ekosistem (planirano)
|
### v0.3 — Konzola i PTY (TRENUTNO)
|
||||||
- Frontend dashboard
|
- xterm.js terminali u browseru
|
||||||
- WebSocket real-time praćenje
|
- Svaki task dobija sopstvenu claude PTY sesiju
|
||||||
- Help sistem
|
- "Pusti" automatski pokrece rad
|
||||||
- Embed SDK
|
- "Proveri" pokrece review sesiju
|
||||||
- Cost tracking dashboard
|
- WebSocket za real-time terminal I/O
|
||||||
- Metrike i analitika
|
- Replay buffer za reconnect
|
||||||
- Distribucija prema licencama
|
- 125+ testova u 12 fajlova
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,55 +1,114 @@
|
|||||||
# KAOS — Master Status
|
# KAOS — Master Status
|
||||||
|
|
||||||
**Verzija:** 0.3.0
|
**Verzija:** v0.3.4
|
||||||
**Poslednje ažuriranje:** 2026-02-20
|
**Poslednje ažuriranje:** 2026-02-20 18:00
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fajl indeks
|
## Stanje fajlova na Windows-u
|
||||||
|
|
||||||
| Fajl | Opis |
|
| Folder | Taskovi |
|
||||||
|------|------|
|
|--------|---------|
|
||||||
| `CLAUDE.md` | Mastermind (v0.3.0) |
|
| backlog/ | T17, T18, T23 |
|
||||||
| `README.md` | Pregled projekta, arhitektura, odluke |
|
| ready/ | T25 |
|
||||||
| `agents/*/CLAUDE.md` | 8 agenata (v0.1.0) |
|
| active/ | — |
|
||||||
| `TASKS/MASTER-STATUS.md` | Ovo — navigacija, status |
|
| review/ | — |
|
||||||
| `TASKS/Implementation-Tasks.md` | Svi taskovi detaljno |
|
| done/ | (server ima T01-T22, T24) |
|
||||||
| `TASKS/Workflow-Spec.md` | 10 koraka, odluke |
|
|
||||||
| `TASKS/Multi-Agent-Spec.md` | Arhitektura agenata |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task folderi
|
## v0.1 Supervisor — ZAVRŠENO ✅ (67 testova)
|
||||||
|
|
||||||
| Folder | Sadržaj | Taskovi |
|
| Task | Naslov | Tag |
|
||||||
|--------|---------|---------|
|
|------|--------|-----|
|
||||||
| backlog/ | Čeka odobrenje | T08, T09, T10 |
|
| T01 | Inicijalizacija Go projekta | v0.1.1 |
|
||||||
| ready/ | Odobren za rad | — |
|
| T02 | Task loader (parsiranje MD) | v0.1.2 |
|
||||||
| active/ | U izradi | — |
|
| T03 | Runner (pokretanje Claude Code) | v0.1.4 |
|
||||||
| review/ | Čeka pregled/odgovor | — |
|
| T04 | Checker (build + test + vet) | v0.1.3 |
|
||||||
| done/ | Završeno | T01, T02, T03, T04, T05, T06, T07 |
|
| T05 | Reporter (pisanje izveštaja) | v0.1.5 |
|
||||||
|
| T06 | CLI (komandni interfejs) | v0.1.6 |
|
||||||
|
| T07 | Integracija (end-to-end) | v0.1.7 |
|
||||||
|
|
||||||
|
## v0.2 Dashboard — ZAVRŠENO ✅ (23 testova)
|
||||||
|
|
||||||
|
| Task | Naslov | Tag |
|
||||||
|
|------|--------|-----|
|
||||||
|
| T08 | HTTP server + API | v0.2.1 |
|
||||||
|
| T09 | Dashboard Kanban + templates | v0.2.2 |
|
||||||
|
| T10 | Drag & Drop + validacija | v0.2.3 |
|
||||||
|
|
||||||
|
## v0.3 UX + Operaterski interfejs — U TOKU
|
||||||
|
|
||||||
|
| Task | Naslov | Tag | Status |
|
||||||
|
|------|--------|-----|--------|
|
||||||
|
| T11 | Fix — svež stanje sa diska | v0.2.4 | done ✅ |
|
||||||
|
| T12 | Prikaz dokumentacije | v0.2.5 | done ✅ |
|
||||||
|
| T13 | Pretraga taskova i dokumentacije | v0.2.6 | done ✅ |
|
||||||
|
| T14 | Konzola (2 sesije) | v0.2.7 | done ✅ |
|
||||||
|
| T15 | Fix — docs širina | v0.2.8 | done ✅ |
|
||||||
|
| T16 | SSE auto-refresh | v0.3.0 | done ✅ |
|
||||||
|
| T19 | Dugme "Pusti" na taskovima | v0.2.9 | done ✅ |
|
||||||
|
| T20 | Workflow dugmad po statusu | v0.3.1 | done ✅ |
|
||||||
|
| T21 | UI fix — konzola, detalj panel, layout | v0.3.2 | done ✅ |
|
||||||
|
| T22 | Prijava — dva moda + dontAsk fix | v0.3.3 | done ✅ |
|
||||||
|
| T24 | PTY za real-time konzolu | v0.3.4 | done ✅ |
|
||||||
|
| T25 | Timestampi + izveštaj prikaz | — | ready |
|
||||||
|
| T26 | Test — prikaži zadnjih 20 linija loga | v0.3.5 | review |
|
||||||
|
| T23 | Persistent logovi + log viewer tab | — | backlog |
|
||||||
|
| T17 | Systemd servis | — | backlog |
|
||||||
|
| T18 | End-to-end test uživo | — | backlog |
|
||||||
|
|
||||||
|
## PROJEKAT UKUPNO: 192 testova ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.1 Taskovi — ZAVRŠENO ✅
|
## Taskovi za kreiranje (dogovoreni, nemaju .md)
|
||||||
|
|
||||||
| Task | Naslov | Tag | Commit | Testova |
|
| # | Šta | Prioritet |
|
||||||
|------|--------|-----|--------|---------|
|
|---|-----|-----------|
|
||||||
| T01 | Inicijalizacija Go projekta | v0.1.1 | f001c53 | 6 |
|
| T26 | Tema: svetla/tamna/auto | nizak |
|
||||||
| T02 | Task loader (parsiranje MD) | v0.1.2 | 79bcd52 | 17 |
|
| T27 | Fix: konzola se prazni posle vremena | srednji |
|
||||||
| T03 | Runner (pokretanje Claude Code) | v0.1.4 | 9d2c249 | 7 |
|
| T28 | Notifikacije (zvuk/badge kad task završi) | srednji |
|
||||||
| T04 | Checker (build + test + vet) | v0.1.3 | 5d869f5 | 10 |
|
| T29 | Task editor iz dashboarda | nizak |
|
||||||
| T05 | Reporter (pisanje izveštaja) | v0.1.5 | 028872b | 10 |
|
| T30 | Autentifikacija (login) | visok (pre produkcije) |
|
||||||
| T06 | CLI (komandni interfejs) | v0.1.6 | 38e1e10 | 9 |
|
| T31 | Deployer agent implementacija | visok |
|
||||||
| T07 | Integracija (end-to-end) | v0.1.7 | b2ece98 | 8 |
|
| T32 | Cost tracking (tokeni, cena, vreme po tasku) | srednji |
|
||||||
| **Ukupno** | | | | **67** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sledeće — v0.2 Dashboard
|
## Poznati bugovi
|
||||||
|
|
||||||
| Task | Naslov | Folder | Zavisi od |
|
| Bug | Task | Status |
|
||||||
|------|--------|--------|-----------|
|
|-----|------|--------|
|
||||||
| T08 | HTTP server + API | backlog | T07 ✅ |
|
| Konzola se prazni posle vremena | T27 | za kreiranje |
|
||||||
| T09 | Dashboard kanban board | backlog | T08 |
|
| Sync vraća obrisane fajlove iz done/ | — | otvoreno |
|
||||||
| T10 | Drag & Drop | backlog | T09 |
|
| "T01 Naslov" template u done/ | — | za brisanje |
|
||||||
|
| Agent menjao CLAUDE.md | — | dodato pravilo "nikad ne menjaj" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ključne odluke
|
||||||
|
|
||||||
|
| Odluka | Detalji |
|
||||||
|
|--------|---------|
|
||||||
|
| HTMX umesto React | Nema npm, jedan binary |
|
||||||
|
| Folder-based state machine | Task stanje = folder lokacija |
|
||||||
|
| Čist kontekst po tasku | Svaki task = nov claude proces |
|
||||||
|
| dontAsk permission mode | Radi kao root bez problema |
|
||||||
|
| Operater mod = claude CLI | Pro licenca, ne API |
|
||||||
|
| Deployer jedini piše backlog/ | Auto-kreiranje taskova iz logova |
|
||||||
|
| Agent nikad ne menja CLAUDE.md | Samo planer |
|
||||||
|
| Max 2 paralelne sesije | Svaka = zaseban claude proces |
|
||||||
|
| Semver tagging | Patch=task, Minor=milestone |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
| Šta | Gde |
|
||||||
|
|-----|-----|
|
||||||
|
| Mastermind pravila | `CLAUDE.md` v0.6.0 |
|
||||||
|
| Workflow spec | `TASKS/Workflow-Spec.md` |
|
||||||
|
| Multi-agent arhitektura | `TASKS/Multi-Agent-Spec.md` |
|
||||||
|
| Agent pravila | `agents/*/CLAUDE.md` |
|
||||||
|
| Template | `templates/new-project/` |
|
||||||
|
|||||||
68
TASKS/reports/T12-report.md
Normal file
68
TASKS/reports/T12-report.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# T12 Izveštaj: Dashboard — prikaz dokumentacije i CLAUDE.md
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Dodat docs viewer na dashboard sa goldmark markdown renderovanjem.
|
||||||
|
|
||||||
|
### Novi fajlovi
|
||||||
|
|
||||||
|
| Fajl | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `internal/server/docs.go` | Docs handleri, goldmark renderovanje, path traversal zaštita, link rewriting |
|
||||||
|
| `web/templates/docs-list.html` | Template za listu .md fajlova |
|
||||||
|
| `web/templates/docs-view.html` | Template za prikaz renderovanog markdowna sa breadcrumbs |
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/server.go` | GET /docs i GET /docs/*path rute |
|
||||||
|
| `internal/server/render.go` | renderDocsList(), renderDocsView(), novi templates u init() |
|
||||||
|
| `internal/server/server_test.go` | 9 novih testova, .md fajlovi u test setup |
|
||||||
|
| `web/static/style.css` | Nav, docs-container, docs-list, docs-content, breadcrumbs stilovi |
|
||||||
|
| `web/templates/layout.html` | Nav bar sa Kanban/Dokumenti linkovima |
|
||||||
|
| `go.mod` / `go.sum` | goldmark v1.7.16 |
|
||||||
|
|
||||||
|
### Endpointi
|
||||||
|
|
||||||
|
| Ruta | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `GET /docs` | Lista svih .md fajlova u projektu |
|
||||||
|
| `GET /docs/{path}` | Renderovan markdown fajl sa breadcrumbs |
|
||||||
|
|
||||||
|
### Bezbednost
|
||||||
|
|
||||||
|
- Samo .md fajlovi (ostalo → 403)
|
||||||
|
- Path traversal zaštita (`../../etc/passwd` → 403)
|
||||||
|
- filepath.Clean + HasPrefix provera
|
||||||
|
|
||||||
|
### Markdown features
|
||||||
|
|
||||||
|
- goldmark sa Table i Strikethrough ekstenzijama
|
||||||
|
- Relativni .md linkovi se rewrite-uju u `/docs/` linkove
|
||||||
|
- Code blokovi, tabele, liste, blockquote — sve renderovano
|
||||||
|
|
||||||
|
### Novi testovi
|
||||||
|
|
||||||
|
```
|
||||||
|
TestDocsList PASS
|
||||||
|
TestDocsView_CLAUDE PASS
|
||||||
|
TestDocsView_NestedFile PASS
|
||||||
|
TestDocsView_PathTraversal PASS
|
||||||
|
TestDocsView_NonMarkdown PASS
|
||||||
|
TestDocsView_NotFound PASS
|
||||||
|
TestDocsView_HasBreadcrumbs PASS
|
||||||
|
TestRewriteLinksSimple PASS
|
||||||
|
TestRewriteLinksSimple_NestedDir PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 101 test, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
70
TASKS/reports/T13-report.md
Normal file
70
TASKS/reports/T13-report.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# T13 Izveštaj: Dashboard — pretraga taskova i dokumentacije
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Dodat search bar na dashboard sa instant pretragom (HTMX debounce 300ms).
|
||||||
|
|
||||||
|
### Novi fajlovi
|
||||||
|
|
||||||
|
| Fajl | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `internal/server/search.go` | Search handler, matchTask, extractSnippet, containsInsensitive |
|
||||||
|
| `web/templates/partials/search-results.html` | Template za dropdown rezultate |
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/server.go` | GET /search ruta |
|
||||||
|
| `internal/server/render.go` | renderSearchResults(), search-results template u init() |
|
||||||
|
| `internal/server/server_test.go` | 8 novih testova, checker agent i report u test setup |
|
||||||
|
| `web/templates/layout.html` | Search input sa hx-get, click-outside zatvaranje |
|
||||||
|
| `web/static/style.css` | Search wrapper, dropdown, result items, status badges, snippet |
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
| Ruta | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `GET /search?q={query}` | HTML fragment sa rezultatima pretrage |
|
||||||
|
|
||||||
|
### Šta pretražuje
|
||||||
|
|
||||||
|
1. **Taskovi** — ID, naslov, opis, agent, status
|
||||||
|
2. **Dokumenti** — svi .md fajlovi (sadržaj)
|
||||||
|
3. **Izveštaji** — reports/*.md (sadržaj)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Case insensitive pretraga
|
||||||
|
- Snippet sa kontekstom oko pogotka
|
||||||
|
- Status badge za taskove (done/active/review...)
|
||||||
|
- Klik na task → otvori detail panel
|
||||||
|
- Klik na dokument → navigacija na /docs/
|
||||||
|
- Max 20 rezultata
|
||||||
|
- Prazan query → prazan odgovor
|
||||||
|
- Nema rezultata → "Nema rezultata" poruka
|
||||||
|
- Click outside → zatvori dropdown
|
||||||
|
|
||||||
|
### Novi testovi — 8 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestSearch_FindsTask PASS
|
||||||
|
TestSearch_FindsTaskByID PASS
|
||||||
|
TestSearch_FindsDocument PASS
|
||||||
|
TestSearch_FindsReport PASS
|
||||||
|
TestSearch_EmptyQuery PASS
|
||||||
|
TestSearch_NoResults PASS
|
||||||
|
TestSearch_CaseInsensitive PASS
|
||||||
|
TestSearch_HasSnippet PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 109 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
71
TASKS/reports/T14-report.md
Normal file
71
TASKS/reports/T14-report.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# T14 Izveštaj: Dashboard — konzola za komunikaciju sa agentom
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Dodata konzola u dashboard — terminal interfejs za pokretanje Claude Code iz browsera.
|
||||||
|
|
||||||
|
### Novi fajlovi
|
||||||
|
|
||||||
|
| Fajl | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `internal/server/console.go` | Console manager, sesije, exec, SSE stream, kill, history |
|
||||||
|
| `web/templates/console.html` | Template sa dva panela, input, output, toolbar |
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/server.go` | Console field u Server, 6 novih ruta, consoleManager init |
|
||||||
|
| `internal/server/render.go` | renderConsolePage(), console template u init() |
|
||||||
|
| `internal/server/server_test.go` | 7 novih testova |
|
||||||
|
| `web/templates/layout.html` | Konzola link u nav |
|
||||||
|
| `web/templates/docs-list.html` | Konzola link u nav |
|
||||||
|
| `web/templates/docs-view.html` | Konzola link u nav |
|
||||||
|
| `web/static/style.css` | Console stilovi (paneli, output, input, status) |
|
||||||
|
|
||||||
|
### Endpointi
|
||||||
|
|
||||||
|
| Ruta | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `GET /console` | Konzola HTML stranica |
|
||||||
|
| `POST /console/exec` | Pokreni komandu (JSON: cmd, session) |
|
||||||
|
| `GET /console/stream/:id` | SSE stream outputa |
|
||||||
|
| `POST /console/kill/:session` | Prekini proces u sesiji |
|
||||||
|
| `GET /console/sessions` | Status obe sesije |
|
||||||
|
| `GET /console/history/:session` | Istorija komandi |
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 2 paralelne sesije (svaka = zaseban Claude Code proces)
|
||||||
|
- SSE streaming outputa u realnom vremenu
|
||||||
|
- Komanda → Enter ili klik dugme
|
||||||
|
- Kill dugme za prekid procesa
|
||||||
|
- Istorija komandi (↑/↓ strelice, max 50 po sesiji)
|
||||||
|
- Second panel toggle (+/- Sesija 2)
|
||||||
|
- Input disabled dok komanda radi
|
||||||
|
- Status badge (idle/running)
|
||||||
|
- Scroll to bottom na novi output
|
||||||
|
- `claude --dangerously-skip-permissions -p` za izvršavanje
|
||||||
|
|
||||||
|
### Novi testovi — 7 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestConsolePage PASS
|
||||||
|
TestConsoleSessions PASS
|
||||||
|
TestConsoleExec_InvalidSession PASS
|
||||||
|
TestConsoleExec_ValidRequest PASS
|
||||||
|
TestConsoleKill_IdleSession PASS
|
||||||
|
TestConsoleHistory_Empty PASS
|
||||||
|
TestConsoleHistory_AfterExec PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 116 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
50
TASKS/reports/T15-report.md
Normal file
50
TASKS/reports/T15-report.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# T15 Izveštaj: Fix — docs viewer zauzima pola ekrana
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Docs viewer pretvoren u sidebar + content layout (25%/75% grid).
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `web/static/style.css` | docs-layout grid (25%/75%), docs-sidebar, docs-main, responsive |
|
||||||
|
| `web/templates/docs-list.html` | Sidebar + content layout sa placeholder |
|
||||||
|
| `web/templates/docs-view.html` | Sidebar sa file listom + content sa breadcrumbs |
|
||||||
|
| `internal/server/docs.go` | Files polje u docsViewData, HTMX fragment detekcija |
|
||||||
|
| `internal/server/server_test.go` | 3 nova testa |
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┬───────────────────────────┐
|
||||||
|
│ Sidebar 25% │ Content 75% │
|
||||||
|
│ File list │ Breadcrumbs + Markdown │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
└──────────────┴───────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- HTMX klik na fajl → swap samo content div (fragment)
|
||||||
|
- Direktan URL pristup → full page sa sidebar
|
||||||
|
- Responsive: na <700px → 1 kolona (100%)
|
||||||
|
- min-height: 80vh
|
||||||
|
|
||||||
|
### Novi testovi — 3 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestDocsView_HasSidebarLayout PASS
|
||||||
|
TestDocsView_HTMXReturnsFragment PASS
|
||||||
|
TestDocsList_HasSidebarLayout PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 119 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
55
TASKS/reports/T16-report.md
Normal file
55
TASKS/reports/T16-report.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# T16 Izveštaj: SSE auto-refresh dashboarda
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Dashboard se automatski ažurira kad se stanje taskova promeni putem SSE.
|
||||||
|
|
||||||
|
### Novi fajlovi
|
||||||
|
|
||||||
|
| Fajl | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `internal/server/events.go` | EventBroker, polling (2s), hashTaskState, SSE handler |
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/server.go` | events field, GET /events ruta, startPolling u Run() |
|
||||||
|
| `internal/server/render.go` | renderBoardFragment() za SSE updates |
|
||||||
|
| `internal/server/server_test.go` | 4 nova testa |
|
||||||
|
| `web/templates/layout.html` | initSSE(), isDragging flag, onStart handler |
|
||||||
|
| `web/templates/partials/column.html` | Uklonjen hx-trigger="every 5s" (zamenjeno SSE-om) |
|
||||||
|
|
||||||
|
### Kako radi
|
||||||
|
|
||||||
|
1. Server poluje TASKS/ svake 2s
|
||||||
|
2. Računa SHA-256 hash svih task ID:status parova
|
||||||
|
3. Ako se hash promenio → broadcast SSE event svim klijentima
|
||||||
|
4. Browser EventSource prima event → zameni board HTML
|
||||||
|
5. Tokom drag & drop — SSE update se ignoruje (isDragging flag)
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
| Ruta | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `GET /events` | SSE stream — event `taskUpdate` sa board HTML |
|
||||||
|
|
||||||
|
### Novi testovi — 4 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestSSE_EventsEndpoint PASS — proverava Content-Type text/event-stream
|
||||||
|
TestHashTaskState PASS — hash se menja kad se status menja, stabilan za isti set
|
||||||
|
TestSSE_BroadcastOnChange PASS — premesti fajl → broadcast event
|
||||||
|
TestSSE_NoBroadcastWithoutChange PASS — bez promene → bez eventa
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 129 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
67
TASKS/reports/T19-report.md
Normal file
67
TASKS/reports/T19-report.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# T19 Izveštaj: Dugme "Pusti" — pokreni agenta u čistoj sesiji
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Dodat "Pusti ▶" dugme na task karticama i POST /task/{id}/run endpoint.
|
||||||
|
|
||||||
|
### Novi/izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/server.go` | POST /task/:id/run ruta, handleRunTask handler |
|
||||||
|
| `internal/server/render.go` | taskCardData wrapper sa CanRun, canRunTask(), doneSet logika |
|
||||||
|
| `internal/server/console.go` | timeNow() helper |
|
||||||
|
| `internal/server/server_test.go` | 6 novih testova |
|
||||||
|
| `web/templates/partials/task-card.html` | "Pusti ▶" dugme sa hx-post |
|
||||||
|
| `web/templates/layout.html` | htmx:afterRequest handler za run response + toast |
|
||||||
|
| `web/static/style.css` | btn-run stil |
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
| Ruta | Opis |
|
||||||
|
|------|------|
|
||||||
|
| `POST /task/{id}/run` | Pokreni task u prvoj slobodnoj sesiji |
|
||||||
|
|
||||||
|
### Logika
|
||||||
|
|
||||||
|
1. Nađi task, proveri status
|
||||||
|
2. backlog → proveri deps (sve u done?), premesti u ready
|
||||||
|
3. ready → nađi slobodnu sesiju (1 ili 2)
|
||||||
|
4. Nema slobodne → 409 "obe sesije zauzete"
|
||||||
|
5. Pokreni `claude --dangerously-skip-permissions -p "..."`
|
||||||
|
6. Output ide u konzolu sesije
|
||||||
|
7. Toast + board refresh
|
||||||
|
|
||||||
|
### Dugme se prikazuje za
|
||||||
|
|
||||||
|
- backlog/ taskove čije su zavisnosti u done/
|
||||||
|
- ready/ taskove
|
||||||
|
- review/ taskove
|
||||||
|
|
||||||
|
### Ne prikazuje se za
|
||||||
|
|
||||||
|
- active/ (već rade)
|
||||||
|
- done/ (završeni)
|
||||||
|
- backlog/ sa neispunjenim zavisnostima
|
||||||
|
|
||||||
|
### Novi testovi — 6 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestRunTask_Ready PASS
|
||||||
|
TestRunTask_BacklogWithDeps PASS
|
||||||
|
TestRunTask_AlreadyDone PASS
|
||||||
|
TestRunTask_NotFound PASS
|
||||||
|
TestRunTask_BothSessionsBusy PASS
|
||||||
|
TestDashboardHTML_HasRunButton PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 125 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
58
TASKS/reports/T20-report.md
Normal file
58
TASKS/reports/T20-report.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# T20 Izveštaj: Workflow dugmad na karticama
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Svaki task prikazuje različito dugme zavisno od stanja i ispunjenosti zavisnosti.
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/render.go` | resolveTaskAction(), Action field, buildDashboardData() |
|
||||||
|
| `internal/server/server_test.go` | 8 novih testova, ažuriran TestTaskDetail_HasMoveButtons |
|
||||||
|
| `web/templates/partials/task-card.html` | Dugmad po Action: blocked/review/run/running/approve/done |
|
||||||
|
| `web/templates/partials/task-detail.html` | Odobri/Vrati/Pusti dugmad u detail panelu |
|
||||||
|
| `web/static/style.css` | task-action, btn-blocked/review/approve/report/running, pulse animacija |
|
||||||
|
|
||||||
|
### Akcije po stanju
|
||||||
|
|
||||||
|
| Status | Uslov | Dugme | Akcija |
|
||||||
|
|--------|-------|-------|--------|
|
||||||
|
| backlog | deps nisu ok | Blokiran | sivo, neklikabilno |
|
||||||
|
| backlog | deps ok | Pregledaj | otvori detail panel |
|
||||||
|
| ready | — | Pusti | pokreni agenta |
|
||||||
|
| active | — | Radi | informativno, pulsira |
|
||||||
|
| review | — | Pregledaj | otvori za pregled |
|
||||||
|
| done | — | Izvestaj | otvori report |
|
||||||
|
|
||||||
|
### Detail panel dugmad
|
||||||
|
|
||||||
|
| Status | Dugmad |
|
||||||
|
|--------|--------|
|
||||||
|
| backlog | Odobri (→ ready) |
|
||||||
|
| ready | Pusti (pokreni agenta) |
|
||||||
|
| review | Odobri (→ done), Vrati (→ ready) |
|
||||||
|
|
||||||
|
### Novi testovi — 8 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestResolveTaskAction_Blocked PASS
|
||||||
|
TestResolveTaskAction_Review PASS
|
||||||
|
TestResolveTaskAction_Run PASS
|
||||||
|
TestResolveTaskAction_Running PASS
|
||||||
|
TestResolveTaskAction_Approve PASS
|
||||||
|
TestResolveTaskAction_Done PASS
|
||||||
|
TestDashboardHTML_BlockedButton PASS
|
||||||
|
TestDashboardHTML_DoneReportButton PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 137 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
56
TASKS/reports/T21-report.md
Normal file
56
TASKS/reports/T21-report.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# T21 Izveštaj: UI poboljšanja (konzola, task detalj, layout)
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Tri UI poboljšanja: task detalj panel 50% ekrana, konzola fullscreen, dokumenti full height.
|
||||||
|
|
||||||
|
### Izmenjeni fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `web/static/style.css` | #task-detail 50% width, body.detail-open board compression, console fullscreen, docs full height, sidebar/main scroll |
|
||||||
|
| `web/templates/layout.html` | detail-open body class toggle, click-outside-to-close za detalj panel |
|
||||||
|
| `web/templates/console.html` | Toolbar premešten iznad panela |
|
||||||
|
| `internal/server/server_test.go` | 5 novih testova |
|
||||||
|
|
||||||
|
### 1. Task detalj panel — 50% ekrana
|
||||||
|
|
||||||
|
- Panel se otvara na 50% širine ekrana (umesto fiksnih 420px)
|
||||||
|
- Board se kompresuje na 50% kad je panel otvoren (`body.detail-open .board { margin-right: 50% }`)
|
||||||
|
- Klik van panela zatvara panel (osim klika na task karticu)
|
||||||
|
- Na mobilnom (<700px) panel je 100% kao overlay
|
||||||
|
- Tranzicija: slide sa desne strane (0.3s ease)
|
||||||
|
|
||||||
|
### 2. Konzola — fullscreen layout
|
||||||
|
|
||||||
|
- Toolbar (+ Sesija 2) premešten iznad panela za bolji UX
|
||||||
|
- `min-height: 80vh` na kontejneru
|
||||||
|
- Input red: `flex-shrink: 0` za uvek vidljiv input
|
||||||
|
- Toolbar: `flex-shrink: 0` za stabilnu poziciju
|
||||||
|
|
||||||
|
### 3. Dokumenti — full height
|
||||||
|
|
||||||
|
- `docs-container`: `height: calc(100vh - 60px)` umesto `min-height: 80vh`
|
||||||
|
- `docs-layout`: `flex: 1; min-height: 0` za ispravno popunjavanje prostora
|
||||||
|
- `docs-sidebar` i `docs-main`: `overflow-y: auto` za nezavisni scroll
|
||||||
|
|
||||||
|
### Novi testovi — 5 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestDashboard_DetailOpenClassInJS PASS
|
||||||
|
TestDashboard_ClickOutsideClosesDetail PASS
|
||||||
|
TestConsolePage_ToolbarAbovePanels PASS
|
||||||
|
TestConsolePage_HasSessionToggle PASS
|
||||||
|
TestDocsPage_HasFullHeightLayout PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 142 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
78
TASKS/reports/T22-report.md
Normal file
78
TASKS/reports/T22-report.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# T22 Izveštaj: Prijava — dva moda (klijent i operater)
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Nova sekcija "Prijava" sa dva moda: klijent (prosta forma) i operater (chat sa Claude API).
|
||||||
|
|
||||||
|
### Izmenjeni/kreirani fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/submit.go` | NOVO — handleri za oba moda, nextTaskNumber, Claude API streaming, chatState |
|
||||||
|
| `web/templates/submit.html` | NOVO — template sa toggle klijent/operater, forma, chat UI |
|
||||||
|
| `internal/server/server.go` | chatMu/chats polja, 4 nove rute, sync import |
|
||||||
|
| `internal/server/render.go` | renderSubmitPage(), registracija submit.html template |
|
||||||
|
| `internal/server/server_test.go` | 13 novih testova |
|
||||||
|
| `web/static/style.css` | CSS za submit, form, chat, priority |
|
||||||
|
| `web/templates/layout.html` | Prijava nav tab |
|
||||||
|
| `web/templates/console.html` | Prijava nav tab |
|
||||||
|
| `web/templates/docs-list.html` | Prijava nav tab |
|
||||||
|
| `web/templates/docs-view.html` | Prijava nav tab |
|
||||||
|
|
||||||
|
### Klijent mod
|
||||||
|
|
||||||
|
- Forma: naslov (obavezno), opis (opciono), prioritet (Nizak/Srednji/Visok)
|
||||||
|
- POST /submit/simple → kreira task u backlog/
|
||||||
|
- Auto-numeracija: skenira sve taskove, nađe max T{XX}, inkrementiraj
|
||||||
|
- Task format sa svim standardnim KAOS poljima + Prioritet + Izvor
|
||||||
|
- Vizuelna potvrda sa task ID-em
|
||||||
|
|
||||||
|
### Operater mod
|
||||||
|
|
||||||
|
- Chat interfejs sa Claude API (Sonnet model)
|
||||||
|
- System prompt: CLAUDE.md + trenutno stanje svih taskova
|
||||||
|
- SSE streaming odgovora (Anthropic streaming API → browser)
|
||||||
|
- Višestruke poruke u istoj sesiji (chat_id)
|
||||||
|
- Automatsko onemogućenje inputa dok Claude odgovara
|
||||||
|
|
||||||
|
### Endpointi
|
||||||
|
|
||||||
|
| Endpoint | Metod | Opis |
|
||||||
|
|----------|-------|------|
|
||||||
|
| /submit | GET | Stranica za prijavu |
|
||||||
|
| /submit/simple | POST | Klijent forma → backlog/ |
|
||||||
|
| /submit/chat | POST | Operater poruka → Claude API |
|
||||||
|
| /submit/chat/stream/:id | GET | SSE stream odgovora |
|
||||||
|
|
||||||
|
### Navigacija
|
||||||
|
|
||||||
|
Novi tab "Prijava" dodat u header svih stranica (Kanban, Dokumenti, Konzola, Prijava).
|
||||||
|
|
||||||
|
### Novi testovi — 13 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestSubmitPage PASS
|
||||||
|
TestSubmitPage_ClientModeIsDefault PASS
|
||||||
|
TestSimpleSubmit_CreatesTask PASS
|
||||||
|
TestSimpleSubmit_MissingTitle PASS
|
||||||
|
TestSimpleSubmit_AutoNumbering PASS
|
||||||
|
TestSimpleSubmit_DefaultPriority PASS
|
||||||
|
TestChatSubmit_NoAPIKey PASS
|
||||||
|
TestChatSubmit_EmptyMessage PASS
|
||||||
|
TestChatStream_NotFound PASS
|
||||||
|
TestNextTaskNumber PASS
|
||||||
|
TestBuildTaskContext PASS
|
||||||
|
TestSubmitPage_HasPrijavaNav PASS
|
||||||
|
TestDashboard_HasPrijavaNav PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 155 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
45
TASKS/reports/T26-report.md
Normal file
45
TASKS/reports/T26-report.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# T26 Izveštaj — Test: prikaži zadnjih 20 linija loga
|
||||||
|
|
||||||
|
## Rezultat: USPEŠNO ✅
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Handler `handleLogsTail` i ruta `/api/logs/tail` su već postojali (iz ranijeg rada).
|
||||||
|
UI link "Logovi" u layoutu je takođe već bio implementiran.
|
||||||
|
|
||||||
|
Ovaj task je dodao **7 novih testova** za logs endpoint:
|
||||||
|
|
||||||
|
| Test | Šta proverava |
|
||||||
|
|------|---------------|
|
||||||
|
| TestHandleLogsTail_OK | 200, vraća tačno 20 linija iz fajla sa 25 linija |
|
||||||
|
| TestHandleLogsTail_LessThan20Lines | Vraća sve linije kad ih ima manje od 20 |
|
||||||
|
| TestHandleLogsTail_NoLogFile | Poruka kad KAOS_LOG_FILE nije podešen |
|
||||||
|
| TestHandleLogsTail_FileNotFound | Poruka kad fajl ne postoji |
|
||||||
|
| TestHandleLogsTail_Max20Lines | Max 20 linija čak iz fajla sa 100 linija |
|
||||||
|
| TestTailLines | Table-driven test (5 slučajeva) za helper funkciju |
|
||||||
|
| TestTailLines_Content | Provera tačnog sadržaja poslednjih linija |
|
||||||
|
|
||||||
|
## Fajlovi
|
||||||
|
|
||||||
|
| Fajl | Akcija |
|
||||||
|
|------|--------|
|
||||||
|
| code/internal/server/logs_test.go | KREIRAN (161 linija) |
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- Novih: 7 (12 sub-testova ukupno)
|
||||||
|
- Svi prolaze ✅
|
||||||
|
- `go build ./...` ✅
|
||||||
|
- `go vet ./...` ✅
|
||||||
|
- PROJEKAT UKUPNO: 192 testova ✅
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
- `4031593` — T26: Testovi za endpoint zadnjih 20 linija loga
|
||||||
|
|
||||||
|
## Vremena
|
||||||
|
|
||||||
|
| Događaj | Vreme |
|
||||||
|
|---------|-------|
|
||||||
|
| Početak | 2026-02-21 04:33 |
|
||||||
|
| Završetak | 2026-02-21 04:40 |
|
||||||
70
TASKS/review/T12.md
Normal file
70
TASKS/review/T12.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# T12: Dashboard — prikaz dokumentacije i CLAUDE.md
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Dodaj tab/sekciju na dashboard za prikaz dokumentacije.
|
||||||
|
Prikazuje CLAUDE.md renderovan kao HTML, sa klikabilnim referencama
|
||||||
|
koje otvaraju referencirane fajlove inline.
|
||||||
|
|
||||||
|
## Endpointi
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /docs → lista svih .md fajlova (HTML)
|
||||||
|
GET /docs/{path...} → renderovan markdown fajl (HTML fragment)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kako radi
|
||||||
|
|
||||||
|
1. Sidebar ili tab "Dokumenti" pored Kanban boarda
|
||||||
|
2. Klik → prikaže CLAUDE.md renderovan kao HTML
|
||||||
|
3. Reference u tabeli (npr. `agents/coder/CLAUDE.md`) su linkovi
|
||||||
|
4. Klik na link → HTMX učita taj fajl: `hx-get="/docs/agents/coder/CLAUDE.md"`
|
||||||
|
5. Breadcrumb navigacija: CLAUDE.md > agents > coder > CLAUDE.md
|
||||||
|
6. Dugme "Nazad" vraća na prethodni fajl
|
||||||
|
|
||||||
|
## Markdown renderovanje
|
||||||
|
|
||||||
|
- Go library: `github.com/gomarkdown/markdown` ili `goldmark`
|
||||||
|
- Renderuj server-side u HTML
|
||||||
|
- Tabele, code blokovi, headeri — sve podržano
|
||||||
|
- Relativne putanje u linkovima → pretvaraju se u /docs/ linkove
|
||||||
|
|
||||||
|
## Fajlovi koji se prikazuju
|
||||||
|
|
||||||
|
Svi .md fajlovi u projektu:
|
||||||
|
- CLAUDE.md (glavni)
|
||||||
|
- agents/*/CLAUDE.md
|
||||||
|
- TASKS/*.md
|
||||||
|
- TASKS/reports/*.md
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
## Bezbednost
|
||||||
|
|
||||||
|
- Samo .md fajlovi (ne dozvoli čitanje .go, .env itd.)
|
||||||
|
- Samo unutar project root-a (ne dozvoli ../../../etc/passwd)
|
||||||
|
- Path traversal zaštita
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- GET /docs → lista fajlova
|
||||||
|
- GET /docs/CLAUDE.md → renderovan HTML
|
||||||
|
- GET /docs/agents/coder/CLAUDE.md → renderovan HTML
|
||||||
|
- GET /docs/../../etc/passwd → 403
|
||||||
|
- GET /docs/main.go → 403 (nije .md)
|
||||||
|
- Klikabilni linkovi u renderovanom HTML-u
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
87
TASKS/review/T13.md
Normal file
87
TASKS/review/T13.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# T13: Dashboard — pretraga taskova i dokumentacije
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Search bar na dashboardu. Pretražuje taskove i .md fajlove.
|
||||||
|
Rezultati se prikazuju instant dok korisnik kuca (debounce 300ms).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /search?q={query} → rezultati pretrage (HTML fragment)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Šta pretražuje
|
||||||
|
|
||||||
|
1. **Taskovi** — ID, naslov, opis, agent, status (folder)
|
||||||
|
2. **Dokumenti** — sadržaj svih .md fajlova
|
||||||
|
3. **Izveštaji** — sadržaj reports/*.md
|
||||||
|
|
||||||
|
## Kako radi
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="search"
|
||||||
|
hx-get="/search"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#search-results"
|
||||||
|
name="q"
|
||||||
|
placeholder="Pretraži taskove i dokumente...">
|
||||||
|
|
||||||
|
<div id="search-results"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format rezultata
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 🔍 "checker" │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 📋 T04: Checker — verifikacija [done] │
|
||||||
|
│ ...go build, go vet, go test... │
|
||||||
|
│ │
|
||||||
|
│ 📄 agents/checker/CLAUDE.md │
|
||||||
|
│ ...Build + Test verifikacija... │
|
||||||
|
│ │
|
||||||
|
│ 📊 T04-report.md │
|
||||||
|
│ ...10 testova, svi prolaze... │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Svaki rezultat:
|
||||||
|
- Ikona po tipu (📋 task, 📄 dokument, 📊 izveštaj)
|
||||||
|
- Naslov/putanja — klikabilan
|
||||||
|
- Snippet sa highlighted match (kontekst oko pogotka)
|
||||||
|
- Klik → otvori task detalj ili dokument
|
||||||
|
|
||||||
|
## Pretraga logika
|
||||||
|
|
||||||
|
- Case insensitive
|
||||||
|
- Pretraži: naslov, sadržaj, ID
|
||||||
|
- Sortiraj: taskovi prvo, pa dokumenti, pa izveštaji
|
||||||
|
- Max 20 rezultata
|
||||||
|
- Prazan query → sakrij rezultate
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- GET /search?q=checker → vrati T04 i checker CLAUDE.md
|
||||||
|
- GET /search?q=T01 → vrati T01 task i T01-report
|
||||||
|
- GET /search?q=deploy → vrati deployer agent CLAUDE.md
|
||||||
|
- GET /search?q= → prazan odgovor
|
||||||
|
- GET /search?q=xyznepostoji → "Nema rezultata"
|
||||||
|
- Snippet sadrži kontekst oko pogotka
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
110
TASKS/review/T14.md
Normal file
110
TASKS/review/T14.md
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# T14: Dashboard — konzola za komunikaciju sa agentom
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Terminal/konzola unutar dashboarda. Operater šalje komande mastermindu,
|
||||||
|
vidi output. Kao chat sa Claude Code-om ali iz browsera.
|
||||||
|
|
||||||
|
## Kako radi
|
||||||
|
|
||||||
|
1. Tab "Konzola" na dashboardu
|
||||||
|
2. Dva panela — mogućnost pokretanja 2 paralelne sesije
|
||||||
|
3. Svaka sesija = zaseban Claude Code proces (`claude` CLI)
|
||||||
|
4. Operater šalje komandu → ENTER
|
||||||
|
5. Server pokrene Claude Code sa tom komandom
|
||||||
|
6. Output se prikazuje u realnom vremenu (SSE stream)
|
||||||
|
7. Kad završi — prompt se vraća
|
||||||
|
|
||||||
|
## Izgled
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┬──────────────────────────┐
|
||||||
|
│ 🔧 Sesija 1 │ 🔧 Sesija 2 │
|
||||||
|
├──────────────────────────┼──────────────────────────┤
|
||||||
|
│ > radi T13 │ > radi T14 │
|
||||||
|
│ ✅ T13 pokrenut... │ ✅ T14 pokrenut... │
|
||||||
|
│ [streaming output] │ [streaming output] │
|
||||||
|
│ ... │ ... │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────────┐ │ ┌────────────────────┐ │
|
||||||
|
│ │ Komanda... [⏎] │ │ │ Komanda... [⏎] │ │
|
||||||
|
│ └────────────────────┘ │ └────────────────────┘ │
|
||||||
|
└──────────────────────────┴──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Operater može koristiti 1 ili 2 panela. Drugi panel se otvara dugmetom [+].
|
||||||
|
|
||||||
|
## Endpointi
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /console/exec → pokreni komandu (body: {"cmd": "...", "session": 1|2})
|
||||||
|
GET /console/stream/{id} → SSE stream outputa
|
||||||
|
GET /console/history/{session} → istorija komandi za sesiju
|
||||||
|
POST /console/kill/{session} → prekini proces u sesiji
|
||||||
|
GET /console/sessions → status obe sesije (idle/running)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Podržane komande
|
||||||
|
|
||||||
|
Konzola poziva kaos-supervisor CLI:
|
||||||
|
|
||||||
|
| Komanda | Šta radi |
|
||||||
|
|---------|----------|
|
||||||
|
| `status` | Prikaži status svih taskova |
|
||||||
|
| `next` | Šta je sledeće za rad |
|
||||||
|
| `verify` | Pokreni verifikaciju |
|
||||||
|
| `history` | Prikaži izvršene taskove |
|
||||||
|
| `radi [TASK_ID]` | Pokreni task |
|
||||||
|
|
||||||
|
Nepoznata komanda → prosleđuje se Claude Code-u kao free-form prompt.
|
||||||
|
|
||||||
|
## SSE streaming
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const source = new EventSource('/console/stream/' + execId);
|
||||||
|
source.onmessage = function(e) {
|
||||||
|
document.getElementById('console-output').innerHTML += e.data + '\n';
|
||||||
|
};
|
||||||
|
source.addEventListener('done', function(e) {
|
||||||
|
source.close();
|
||||||
|
// vrati prompt
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pravila
|
||||||
|
|
||||||
|
- Max 2 paralelne sesije (svaka = zaseban `claude` proces)
|
||||||
|
- Sesije ne smeju raditi na istom tasku
|
||||||
|
- Server proverava: ako je task već active/ u drugoj sesiji → odbij
|
||||||
|
- Timeout: KAOS_TIMEOUT iz .env (po sesiji)
|
||||||
|
- Output se čuva u memoriji (poslednje 50 komandi po sesiji)
|
||||||
|
- Scroll to bottom na novi output
|
||||||
|
- Ctrl+C → prekini trenutnu komandu (POST /console/kill/{session})
|
||||||
|
- Istorija komandi: ↑/↓ strelice (po sesiji)
|
||||||
|
- Claude Code se pokreće: `claude --dangerously-skip-permissions`
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- POST /console/exec {"cmd": "status", "session": 1} → 200 + exec ID
|
||||||
|
- GET /console/stream/{id} → SSE stream
|
||||||
|
- Dve sesije paralelno → obe rade
|
||||||
|
- Isti task u obe sesije → druga odbijena
|
||||||
|
- POST /console/kill/1 → prekine sesiju 1
|
||||||
|
- GET /console/sessions → status obe sesije
|
||||||
|
- GET /console/history/1 → lista komandi sesije 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
53
TASKS/review/T15.md
Normal file
53
TASKS/review/T15.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# T15: Fix — docs viewer zauzima pola ekrana
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T12 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
BUG/UI: Docs viewer je premali. Treba da zauzima pola ekrana (50%ширine).
|
||||||
|
|
||||||
|
## Izmena
|
||||||
|
|
||||||
|
Layout kad je docs otvoren:
|
||||||
|
```
|
||||||
|
┌──────────────────────┬──────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ Lista fajlova │ Sadržaj .md fajla │
|
||||||
|
│ (sidebar 25%) │ (content 75%) │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
└──────────────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Ceo docs tab zauzima minimalno 50% viewport širine.
|
||||||
|
Sadržaj fajla: max-width nema ograničenja, koristi sav prostor.
|
||||||
|
Visina: min-height 80vh da ne bude stisnuto.
|
||||||
|
|
||||||
|
## Fajlovi za izmenu
|
||||||
|
|
||||||
|
```
|
||||||
|
code/web/static/style.css ← širina docs containera
|
||||||
|
code/web/templates/docs.html ← ako treba layout fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- Otvori /docs → zauzima puno ekrana
|
||||||
|
- Renderovan markdown čitljiv na celoj širini
|
||||||
|
- Responsive: na manjem ekranu 100% širine
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
56
TASKS/review/T16.md
Normal file
56
TASKS/review/T16.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# T16: SSE auto-refresh dashboarda
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Dashboard se automatski ažurira kad se stanje taskova promeni.
|
||||||
|
Server šalje SSE event kad se fajl premesti. Board se sam osveži.
|
||||||
|
|
||||||
|
## Kako radi
|
||||||
|
|
||||||
|
1. Server prati TASKS/ foldere (fsnotify ili polling svake 2s)
|
||||||
|
2. Kad se fajl premesti/doda/obriše → pošalje SSE event
|
||||||
|
3. Dashboard sluša SSE → HTMX zameni board HTML
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="board" hx-ext="sse" sse-connect="/events" sse-swap="taskUpdate">
|
||||||
|
<!-- kolone se zamene kad stigne event -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /events → SSE stream
|
||||||
|
event: taskUpdate
|
||||||
|
data: <html fragment svih kolona>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pravila
|
||||||
|
|
||||||
|
- Polling svake 2s (jednostavnije od fsnotify za v0.3)
|
||||||
|
- Šalje event SAMO kad se stanje promeni (pamti hash prethodnog stanja)
|
||||||
|
- Reconnect automatski (EventSource default ponašanje)
|
||||||
|
- Ne kvari drag & drop (event se ne šalje dok je drag aktivan)
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- GET /events → SSE konekcija uspostavljena
|
||||||
|
- Premesti fajl → event poslat u roku od 3s
|
||||||
|
- Nema promena → nema nepotrebnih eventova
|
||||||
|
- Reconnect posle prekida
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
109
TASKS/review/T19.md
Normal file
109
TASKS/review/T19.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# T19: Dugme "Pusti" na svakom tasku — pokreni agenta u čistoj sesiji
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T14 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Svaki task u backlog/ready koloni ima dugme "Pusti".
|
||||||
|
Klik → server pokrene NOVI Claude Code proces sa ČISTIM kontekstom.
|
||||||
|
Agent zna SAMO: CLAUDE.md + svoj task fajl. Ništa drugo.
|
||||||
|
|
||||||
|
## KLJUČNO — čist kontekst
|
||||||
|
|
||||||
|
Svaki task se pokreće u zasebnom `claude` procesu:
|
||||||
|
```bash
|
||||||
|
claude --dangerously-skip-permissions -p "Pročitaj CLAUDE.md i radi task TASKS/ready/T{XX}.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent NEMA kontekst iz prethodnih taskova.
|
||||||
|
Agent NEMA istoriju razgovora.
|
||||||
|
Agent čita CLAUDE.md → razume pravila → čita task → radi.
|
||||||
|
|
||||||
|
## Workflow sa dugmetom
|
||||||
|
|
||||||
|
1. Operater vidi task karticu na boardu
|
||||||
|
2. Klik "Pusti ▶" na kartici
|
||||||
|
3. Ako je task u backlog/ → server ga premesti u ready/ pa pokrene
|
||||||
|
4. Ako je task u ready/ → server ga odmah pokrene
|
||||||
|
5. Server pokrene novi `claude` proces (čist kontekst)
|
||||||
|
6. Output ide u konzolu (sesija 1 ili 2, prva slobodna)
|
||||||
|
7. Kartica se pomeri u Active kolonu (automatski)
|
||||||
|
|
||||||
|
## Izgled kartice
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T15 │
|
||||||
|
│ Fix — docs širina │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ Zavisi od: T12 ✅ │
|
||||||
|
│ [Pusti ▶] │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Dugme se prikazuje SAMO za:
|
||||||
|
- backlog/ taskove čije su zavisnosti ispunjene (sve u done/)
|
||||||
|
- ready/ taskove
|
||||||
|
- review/ taskove koji imaju odgovor (## Odgovori nije prazan)
|
||||||
|
|
||||||
|
Dugme se NE prikazuje za:
|
||||||
|
- active/ taskove (već rade)
|
||||||
|
- done/ taskove (završeni)
|
||||||
|
- backlog/ taskove čije zavisnosti nisu ispunjene
|
||||||
|
|
||||||
|
## Endpointi
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /task/{id}/run → pokreni task u čistoj sesiji
|
||||||
|
Response: {"session": 1, "status": "started"}
|
||||||
|
|
||||||
|
POST /task/{id}/run?session=2 → pokreni u konkretnoj sesiji
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server logika za /task/{id}/run
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Nađi task (ScanTasks)
|
||||||
|
2. Proveri da zavisnosti su u done/
|
||||||
|
3. Ako je u backlog/ → premesti u ready/
|
||||||
|
4. Nađi slobodnu sesiju (1 ili 2)
|
||||||
|
5. Ako nema slobodne → vrati 409 "Obe sesije zauzete"
|
||||||
|
6. Pokreni: claude --dangerously-skip-permissions -p "..."
|
||||||
|
7. Poveži output sa konzolom sesije
|
||||||
|
8. Vrati session ID
|
||||||
|
9. Dashboard se ažurira (SSE ili HTMX swap)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prompt za agenta
|
||||||
|
|
||||||
|
```
|
||||||
|
Pročitaj CLAUDE.md u root-u projekta.
|
||||||
|
Tvoj task: TASKS/ready/T{XX}.md
|
||||||
|
Pročitaj task fajl i uradi šta piše.
|
||||||
|
Prati pravila iz CLAUDE.md — build, test, commit, tag, izveštaj.
|
||||||
|
```
|
||||||
|
|
||||||
|
Kratak, čist. Agent sam čita CLAUDE.md i task.
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- POST /task/T15/run → pokrene proces, vrati session ID
|
||||||
|
- Task premešten u active/ posle pokretanja
|
||||||
|
- Druga sesija: POST /task/T16/run → pokrene u sesiji 2
|
||||||
|
- Obe sesije zauzete: POST /task/T17/run → 409
|
||||||
|
- Task sa neispunjenim zavisnostima → 400
|
||||||
|
- Task koji je već active → 400
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
150
TASKS/review/T20.md
Normal file
150
TASKS/review/T20.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# T20: Fix — dugmad po statusu taska (workflow kontrole)
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T19 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Svaki task prikazuje RAZLIČITO dugme zavisno od stanja i uslova.
|
||||||
|
Operater mora da pregleda i odobri pre pokretanja.
|
||||||
|
|
||||||
|
## Dugmad po stanju
|
||||||
|
|
||||||
|
### backlog/ — zavisnosti NISU ispunjene
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T18 │
|
||||||
|
│ End-to-end test │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ Zavisi od: T17 ⏳ │
|
||||||
|
│ [Blokiran 🔒] │ ← sivo, nije klikabilno
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### backlog/ — zavisnosti JESU ispunjene
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T16 │
|
||||||
|
│ SSE auto-refresh │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ Zavisi od: T15 ✅ │
|
||||||
|
│ [Pregledaj 👁] │ ← otvori sadržaj taska
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Klik "Pregledaj 👁" → otvori task sadržaj u panelu.
|
||||||
|
U panelu prikaži dugme [Odobri ✅] koje premesti u ready/.
|
||||||
|
|
||||||
|
### ready/ — odobren, spreman za rad
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T16 │
|
||||||
|
│ SSE auto-refresh │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ Zavisi od: T15 ✅ │
|
||||||
|
│ [Pusti ▶] │ ← pokreni agenta
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Klik "Pusti ▶" → pokreni Claude Code u čistoj sesiji.
|
||||||
|
SAMO ready/ taskovi imaju Play dugme.
|
||||||
|
|
||||||
|
### active/ — agent radi
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T16 │
|
||||||
|
│ SSE auto-refresh │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ [Radi ⚙️] │ ← informativno, nije klikabilno
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### review/ — agent završio, čeka pregled
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T16 │
|
||||||
|
│ SSE auto-refresh │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ [Pregledaj 👁] │ ← otvori izveštaj
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Klik → otvori izveštaj + task sadržaj u panelu.
|
||||||
|
U panelu dva dugmeta:
|
||||||
|
- [Odobri ✅] → premesti u done/
|
||||||
|
- [Vrati ↩] → premesti u ready/ (za doradu)
|
||||||
|
|
||||||
|
### review/ — agent ima pitanje
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T16 │
|
||||||
|
│ SSE auto-refresh │
|
||||||
|
│ coder · Sonnet │
|
||||||
|
│ ❓ Pitanje čeka odgovor │
|
||||||
|
│ [Odgovori 💬] │ ← otvori pitanje
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Klik → otvori task sa pitanjem. Operater vidi ## Pitanja sekciju.
|
||||||
|
Treba mogućnost da upiše odgovor u ## Odgovori sekciju.
|
||||||
|
Posle odgovora: dugme [Nastavi ▶] pokrene agenta ponovo.
|
||||||
|
|
||||||
|
### done/ — završen
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ T01 ✅ │
|
||||||
|
│ Inicijalizacija Go projekta│
|
||||||
|
│ v0.1.1 │
|
||||||
|
│ [Izveštaj 📊] │ ← otvori report
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logika za server
|
||||||
|
|
||||||
|
```
|
||||||
|
Za svaki task:
|
||||||
|
1. Pročitaj zavisnosti iz fajla (## Zavisi od)
|
||||||
|
2. Proveri da li su sve zavisnosti u done/
|
||||||
|
3. Ako je review/ → proveri da li ## Pitanja postoji i nije prazno
|
||||||
|
4. Na osnovu toga odluči koje dugme
|
||||||
|
|
||||||
|
Funkcija: resolveTaskAction(task) → action string
|
||||||
|
- "blocked" → zavisnosti nisu ispunjene
|
||||||
|
- "review" → u backlog/, zavisnosti ok, čeka pregled
|
||||||
|
- "run" → u ready/, spreman za pokretanje
|
||||||
|
- "running" → u active/
|
||||||
|
- "question" → u review/, ima pitanje bez odgovora
|
||||||
|
- "approve" → u review/, završen, čeka odobrenje
|
||||||
|
- "done" → u done/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pozicija dugmeta
|
||||||
|
|
||||||
|
Uvek desno dole na kartici. Kartica ima `display: flex; flex-direction: column;`
|
||||||
|
Dugme u `margin-top: auto;` kontejneru da bude na dnu.
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- Task u backlog/ bez zavisnosti → "Pregledaj 👁"
|
||||||
|
- Task u backlog/ sa blokirajućom zavisnošću → "Blokiran 🔒"
|
||||||
|
- Task u ready/ → "Pusti ▶"
|
||||||
|
- Task u active/ → "Radi ⚙️" (neklikabilno)
|
||||||
|
- Task u review/ završen → "Pregledaj 👁"
|
||||||
|
- Task u review/ sa pitanjem → "Odgovori 💬"
|
||||||
|
- Task u done/ → "Izveštaj 📊"
|
||||||
|
- Klik "Odobri" na backlog task → premešten u ready/
|
||||||
|
- Klik "Pusti" na ready task → agent pokrenut
|
||||||
|
- Klik "Odobri" na review task → premešten u done/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
53
TASKS/review/T26.md
Normal file
53
TASKS/review/T26.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# T26: Test — prikaži zadnjih 20 linija loga
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Jednostavan test task. Dodaj endpoint koji prikazuje zadnjih 20 linija
|
||||||
|
server loga.
|
||||||
|
|
||||||
|
## Šta treba
|
||||||
|
|
||||||
|
1. Endpoint: GET /api/logs/tail
|
||||||
|
2. Čita zadnjih 20 linija iz stdout/journalctl loga servera
|
||||||
|
3. Vraća plain text
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Ako server piše u fajl:
|
||||||
|
tail -20 /tmp/kaos-server.log
|
||||||
|
|
||||||
|
// Ili journalctl:
|
||||||
|
journalctl -u kaos-server -n 20 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Na dashboardu: dodaj link "Poslednji logovi" negde vidljivo
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
- GET /api/logs/tail → 200, vraća tekst
|
||||||
|
- Odgovor ima max 20 linija
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitanja
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Odgovori
|
||||||
|
|
||||||
|
## Vremena
|
||||||
|
|
||||||
|
| Događaj | Vreme |
|
||||||
|
|---------|-------|
|
||||||
|
| Pokrenut | 2026-02-20 15:52 |
|
||||||
|
| Pokrenut | 2026-02-21 04:05 |
|
||||||
|
| Pokrenut (→active) | 2026-02-21 04:11 |
|
||||||
|
| Pokrenut (→active) | 2026-02-21 04:33 |
|
||||||
|
| Završen (→review) | 2026-02-21 04:40 |
|
||||||
66
TESTING.md
Normal file
66
TESTING.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# KAOS — Test Checklist
|
||||||
|
|
||||||
|
## Dashboard (Kanban)
|
||||||
|
- [ ] Ucitavanje sa svim kolonama (backlog, ready, active, review, done)
|
||||||
|
- [ ] Prikaz taskova u ispravnim kolonama
|
||||||
|
- [ ] Klik na task otvara detail modal
|
||||||
|
- [ ] Escape zatvara modal
|
||||||
|
- [ ] Klik na pozadinu zatvara modal
|
||||||
|
- [ ] Drag & drop premestanje taskova
|
||||||
|
- [ ] SSE auto-refresh kad se task promeni
|
||||||
|
- [ ] Tema (svetla/tamna/auto) radi
|
||||||
|
|
||||||
|
## Task akcije
|
||||||
|
- [ ] "Odobri" premesta backlog -> ready
|
||||||
|
- [ ] "Pusti" premesta ready -> active i pokrece claude
|
||||||
|
- [ ] "Pregledaj" otvara task detail za review
|
||||||
|
- [ ] "Proveri" pokrece review claude sesiju
|
||||||
|
- [ ] "Odobri" (review) premesta review -> done
|
||||||
|
- [ ] "Vrati" premesta review -> ready
|
||||||
|
- [ ] "Izvestaj" otvara report modal
|
||||||
|
- [ ] Blokiran task prikazuje "Blokiran" dugme
|
||||||
|
|
||||||
|
## Konzola
|
||||||
|
- [ ] Prazna stranica kad nema sesija (empty state)
|
||||||
|
- [ ] Posle "Pusti" - terminal se pojavljuje
|
||||||
|
- [ ] Terminal prikazuje claude output u realnom vremenu
|
||||||
|
- [ ] Keyboard input radi (moze se tipkati u terminal)
|
||||||
|
- [ ] Terminal resize radi
|
||||||
|
- [ ] Reconnect na page reload (replay buffer)
|
||||||
|
- [ ] "Ugasi" dugme zavrsava sesiju
|
||||||
|
- [ ] Vise sesija istovremeno
|
||||||
|
- [ ] Review sesija prikazuje "[pregled]" label
|
||||||
|
|
||||||
|
## Pretraga
|
||||||
|
- [ ] Pretrazuje taskove po naslovu i ID-u
|
||||||
|
- [ ] Pretrazuje dokumente
|
||||||
|
- [ ] Pretrazuje izvestaje
|
||||||
|
- [ ] Case-insensitive
|
||||||
|
- [ ] Prazna pretraga ne vraca rezultate
|
||||||
|
- [ ] "Nema rezultata" za nepostojeci termin
|
||||||
|
|
||||||
|
## Dokumenti
|
||||||
|
- [ ] Lista svih .md fajlova
|
||||||
|
- [ ] Pregled sa markdown renderovanjem
|
||||||
|
- [ ] Tabele se renderuju
|
||||||
|
- [ ] Breadcrumbs navigacija
|
||||||
|
- [ ] Sidebar sa listom fajlova
|
||||||
|
- [ ] Path traversal blokiran
|
||||||
|
|
||||||
|
## Prijava
|
||||||
|
- [ ] Klijent mod: forma za prijavu
|
||||||
|
- [ ] Operater mod: chat sa claude
|
||||||
|
- [ ] Task se kreira u backlog/ sa ispravnim ID-om
|
||||||
|
- [ ] Prazan naslov vraca gresku
|
||||||
|
|
||||||
|
## Server logovi
|
||||||
|
- [ ] Prikaz poslednjih logova
|
||||||
|
- [ ] Modal se zatvara
|
||||||
|
|
||||||
|
## API
|
||||||
|
- [ ] GET /api/tasks vraca JSON listu
|
||||||
|
- [ ] GET /api/task/:id vraca detalj sa sadrzajem
|
||||||
|
- [ ] POST /api/task/:id/move premesta task
|
||||||
|
- [ ] Nepostojeci task vraca 404
|
||||||
|
- [ ] Nevalidan folder vraca 400
|
||||||
|
- [ ] Zabranjeni prelazi vracaju 403
|
||||||
@ -2,3 +2,4 @@ KAOS_TIMEOUT=30m
|
|||||||
KAOS_PROJECT_PATH=.
|
KAOS_PROJECT_PATH=.
|
||||||
KAOS_TASKS_DIR=../TASKS
|
KAOS_TASKS_DIR=../TASKS
|
||||||
KAOS_PORT=8080
|
KAOS_PORT=8080
|
||||||
|
KAOS_LOG_FILE=/tmp/kaos-server.log
|
||||||
|
|||||||
@ -8,6 +8,7 @@ require (
|
|||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
@ -15,6 +16,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@ -26,6 +28,7 @@ require (
|
|||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.16 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
|
|||||||
@ -4,6 +4,8 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
|
|||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -28,6 +30,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@ -61,6 +65,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
|
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
|||||||
@ -20,6 +20,8 @@ type Config struct {
|
|||||||
TasksDir string
|
TasksDir string
|
||||||
// Port is the HTTP server port.
|
// Port is the HTTP server port.
|
||||||
Port string
|
Port string
|
||||||
|
// LogFile is the path to the server log file (optional).
|
||||||
|
LogFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables.
|
// Load reads configuration from environment variables.
|
||||||
@ -53,11 +55,14 @@ func Load() (*Config, error) {
|
|||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logFile := os.Getenv("KAOS_LOG_FILE")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
ProjectPath: projectPath,
|
ProjectPath: projectPath,
|
||||||
TasksDir: tasksDir,
|
TasksDir: tasksDir,
|
||||||
Port: port,
|
Port: port,
|
||||||
|
LogFile: logFile,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
205
code/internal/server/api_test.go
Normal file
205
code/internal/server/api_test.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIGetTasks(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks []taskResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &tasks); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tasks) != 2 {
|
||||||
|
t.Fatalf("expected 2 tasks, got %d", len(tasks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that tasks have correct statuses
|
||||||
|
statuses := map[string]string{}
|
||||||
|
for _, task := range tasks {
|
||||||
|
statuses[task.ID] = task.Status
|
||||||
|
}
|
||||||
|
if statuses["T01"] != "done" {
|
||||||
|
t.Errorf("expected T01 status done, got %s", statuses["T01"])
|
||||||
|
}
|
||||||
|
if statuses["T08"] != "backlog" {
|
||||||
|
t.Errorf("expected T08 status backlog, got %s", statuses["T08"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/task/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail taskDetailResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if detail.ID != "T01" {
|
||||||
|
t.Errorf("expected T01, got %s", detail.ID)
|
||||||
|
}
|
||||||
|
if detail.Content == "" {
|
||||||
|
t.Error("expected non-empty content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetTask_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/task/T99", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was moved
|
||||||
|
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err != nil {
|
||||||
|
t.Error("expected T08.md in ready/")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "backlog", "T08.md")); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected T08.md removed from backlog/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T99/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_InvalidFolder(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=invalid", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_ForbiddenToActive(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Put T08 in ready first
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to move ready → active (agent-only)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=active", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 for ready→active, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_ForbiddenActiveToReview(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Put T08 in active
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "active", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to move active → review (agent-only)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=review", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 for active→review, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_AllowedBacklogToReady(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for backlog→ready, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_AllowedReviewToDone(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Put T08 in review
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for review→done, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveTask_AddsTimestamp(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
|
||||||
|
text := string(content)
|
||||||
|
if !containsStr(text, "Odobren (→ready)") {
|
||||||
|
t.Error("expected timestamp in API-moved task file")
|
||||||
|
}
|
||||||
|
}
|
||||||
275
code/internal/server/console.go
Normal file
275
code/internal/server/console.go
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskSession represents a PTY session tied to a specific task.
|
||||||
|
type taskSession struct {
|
||||||
|
TaskID string
|
||||||
|
Type string // "work" or "review"
|
||||||
|
PTY *consolePTYSession
|
||||||
|
Started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskSessionResponse is the JSON representation of a task session.
|
||||||
|
type taskSessionResponse struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"` // "running" or "exited"
|
||||||
|
Started string `json:"started"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskSessionManager manages dynamic PTY sessions per task.
|
||||||
|
type taskSessionManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*taskSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskSessionManager() *taskSessionManager {
|
||||||
|
return &taskSessionManager{
|
||||||
|
sessions: make(map[string]*taskSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionKey returns a unique key for a task session.
|
||||||
|
func sessionKey(taskID, sessionType string) string {
|
||||||
|
if sessionType == "review" {
|
||||||
|
return taskID + "-review"
|
||||||
|
}
|
||||||
|
return taskID
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession spawns an interactive claude PTY and sends the task prompt.
|
||||||
|
func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prompt string) (*taskSession, error) {
|
||||||
|
key := sessionKey(taskID, sessionType)
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
if _, exists := sm.sessions[key]; exists {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("sesija %s već postoji", key)
|
||||||
|
}
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
ptySess, err := spawnTaskPTY(projectDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &taskSession{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: sessionType,
|
||||||
|
PTY: ptySess,
|
||||||
|
Started: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
sm.sessions[key] = sess
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid)
|
||||||
|
|
||||||
|
// Write prompt to file, then send a one-liner to claude
|
||||||
|
// (multi-line prompt can't be typed into PTY — each \n submits early)
|
||||||
|
promptFile := filepath.Join(os.TempDir(), fmt.Sprintf("kaos-%s-prompt.txt", key))
|
||||||
|
if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil {
|
||||||
|
log.Printf("Session[%s]: failed to write prompt file: %v", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
|
||||||
|
ch := ptySess.Subscribe(subID)
|
||||||
|
|
||||||
|
timer := time.NewTimer(30 * time.Second)
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
// Claude is alive and producing output
|
||||||
|
case <-timer.C:
|
||||||
|
log.Printf("Session[%s]: timeout waiting for claude to start", key)
|
||||||
|
case <-ptySess.Done():
|
||||||
|
log.Printf("Session[%s]: claude exited before producing output", key)
|
||||||
|
ptySess.Unsubscribe(subID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
|
ptySess.Unsubscribe(subID)
|
||||||
|
|
||||||
|
// Let claude fully render its welcome screen
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Send one-liner that tells claude to read the prompt file
|
||||||
|
oneliner := fmt.Sprintf("Pročitaj fajl %s i uradi SVE što piše unutra.", promptFile)
|
||||||
|
log.Printf("Session[%s]: sending prompt via file %s (%d bytes)", key, promptFile, len(prompt))
|
||||||
|
ptySess.WriteInput([]byte(oneliner + "\n"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSessionByKey returns a session by its full key.
|
||||||
|
func (sm *taskSessionManager) getSessionByKey(key string) *taskSession {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
return sm.sessions[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSessions returns all active sessions.
|
||||||
|
func (sm *taskSessionManager) listSessions() []taskSessionResponse {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]taskSessionResponse, 0, len(sm.sessions))
|
||||||
|
for _, sess := range sm.sessions {
|
||||||
|
status := "running"
|
||||||
|
select {
|
||||||
|
case <-sess.PTY.Done():
|
||||||
|
status = "exited"
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, taskSessionResponse{
|
||||||
|
TaskID: sess.TaskID,
|
||||||
|
Type: sess.Type,
|
||||||
|
Status: status,
|
||||||
|
Started: sess.Started.Format("15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// killSession terminates a session and removes it.
|
||||||
|
func (sm *taskSessionManager) killSession(taskID, sessionType string) bool {
|
||||||
|
key := sessionKey(taskID, sessionType)
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
sess, exists := sm.sessions[key]
|
||||||
|
if exists {
|
||||||
|
delete(sm.sessions, key)
|
||||||
|
}
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Session[%s]: killed", key)
|
||||||
|
sess.PTY.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConsolePage serves the console HTML page.
|
||||||
|
func (s *Server) handleConsolePage(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, renderConsolePage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConsoleSessions returns all active task sessions as JSON.
|
||||||
|
func (s *Server) handleConsoleSessions(c *gin.Context) {
|
||||||
|
sessions := s.console.listSessions()
|
||||||
|
c.JSON(http.StatusOK, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConsoleKill kills a task session.
|
||||||
|
func (s *Server) handleConsoleKill(c *gin.Context) {
|
||||||
|
taskID := c.Param("taskID")
|
||||||
|
sessionType := c.DefaultQuery("type", "work")
|
||||||
|
|
||||||
|
if s.console.killSession(taskID, sessionType) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "killed", "task": taskID})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWorkPrompt builds the prompt for a work session.
|
||||||
|
func buildWorkPrompt(taskID string, taskContent []byte) string {
|
||||||
|
return fmt.Sprintf(`Radiš na tasku %s. Evo sadržaj taska:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
PRAVILA:
|
||||||
|
1. Pročitaj agents/coder/CLAUDE.md za pravila kodiranja
|
||||||
|
2. Kod piši u code/ folderu
|
||||||
|
3. Svi testovi moraju proći: go test ./... -count=1
|
||||||
|
4. Build mora proći: go build ./...
|
||||||
|
5. go vet ./... mora proći
|
||||||
|
6. Commituj sa porukom: %s: Opis na srpskom
|
||||||
|
7. Napiši izveštaj u TASKS/reports/%s-report.md
|
||||||
|
8. Premesti task fajl: mv TASKS/active/%s.md TASKS/review/%s.md
|
||||||
|
9. Kada sve završiš, reci "GOTOVO"`, taskID, string(taskContent), taskID, taskID, taskID, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReviewPrompt builds the prompt for a review session.
|
||||||
|
func buildReviewPrompt(taskID string, taskContent, reportContent []byte) string {
|
||||||
|
prompt := fmt.Sprintf(`Pregledaj task %s. Evo sadržaj taska:
|
||||||
|
|
||||||
|
%s
|
||||||
|
`, taskID, string(taskContent))
|
||||||
|
|
||||||
|
if len(reportContent) > 0 {
|
||||||
|
prompt += fmt.Sprintf(`
|
||||||
|
Izveštaj agenta:
|
||||||
|
|
||||||
|
%s
|
||||||
|
`, string(reportContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += fmt.Sprintf(`
|
||||||
|
KORACI PREGLEDA:
|
||||||
|
1. Pročitaj agents/checker/CLAUDE.md za pravila
|
||||||
|
2. Proveri da li je kod napisan prema zadatku
|
||||||
|
3. Pokreni testove: go test ./... -count=1
|
||||||
|
4. Pokreni build: go build ./...
|
||||||
|
5. Pokreni vet: go vet ./...
|
||||||
|
6. Ako je SVE u redu:
|
||||||
|
- Premesti task: mv TASKS/review/%s.md TASKS/done/%s.md
|
||||||
|
- Reci "ODOBRENO"
|
||||||
|
7. Ako NIJE u redu:
|
||||||
|
- Zapiši šta treba popraviti u task fajl
|
||||||
|
- Premesti task: mv TASKS/review/%s.md TASKS/active/%s.md
|
||||||
|
- Reci "VRAĆENO NA DORADU"`, taskID, taskID, taskID, taskID)
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReviewTask launches a review claude session for a task.
|
||||||
|
func (s *Server) handleReviewTask(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
// Read task content
|
||||||
|
taskPath := filepath.Join(s.Config.TasksDir, "review", id+".md")
|
||||||
|
taskContent, err := os.ReadFile(taskPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "task nije pronađen u review/"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read report if exists
|
||||||
|
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
|
||||||
|
reportContent, _ := os.ReadFile(reportPath)
|
||||||
|
|
||||||
|
// Build review prompt
|
||||||
|
prompt := buildReviewPrompt(id, taskContent, reportContent)
|
||||||
|
|
||||||
|
// Start review session
|
||||||
|
_, err = s.console.startSession(id, "review", s.projectRoot(), prompt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "started", "task": id, "type": "review"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeNow returns the current time formatted as HH:MM:SS.
|
||||||
|
func timeNow() string {
|
||||||
|
return time.Now().Format("15:04:05")
|
||||||
|
}
|
||||||
312
code/internal/server/console_test.go
Normal file
312
code/internal/server/console_test.go
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsolePage(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "KAOS") {
|
||||||
|
t.Error("expected 'KAOS' in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Konzola") {
|
||||||
|
t.Error("expected 'Konzola' in console page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleSessions_Empty(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions []taskSessionResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sessions) != 0 {
|
||||||
|
t.Fatalf("expected 0 sessions, got %d", len(sessions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleKill_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionKey(t *testing.T) {
|
||||||
|
if got := sessionKey("T01", "work"); got != "T01" {
|
||||||
|
t.Errorf("expected T01, got %s", got)
|
||||||
|
}
|
||||||
|
if got := sessionKey("T01", "review"); got != "T01-review" {
|
||||||
|
t.Errorf("expected T01-review, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSessionManager_ListEmpty(t *testing.T) {
|
||||||
|
sm := newTaskSessionManager()
|
||||||
|
sessions := sm.listSessions()
|
||||||
|
if len(sessions) != 0 {
|
||||||
|
t.Errorf("expected 0 sessions, got %d", len(sessions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSessionManager_KillNotFound(t *testing.T) {
|
||||||
|
sm := newTaskSessionManager()
|
||||||
|
if sm.killSession("T99", "work") {
|
||||||
|
t.Error("expected false for non-existent session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSessionManager_GetNotFound(t *testing.T) {
|
||||||
|
sm := newTaskSessionManager()
|
||||||
|
if sm.getSessionByKey("T99") != nil {
|
||||||
|
t.Error("expected nil for non-existent session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWorkPrompt(t *testing.T) {
|
||||||
|
prompt := buildWorkPrompt("T08", []byte("# T08: Test task\n\nOpis."))
|
||||||
|
|
||||||
|
if !containsStr(prompt, "T08") {
|
||||||
|
t.Error("expected task ID in prompt")
|
||||||
|
}
|
||||||
|
if !containsStr(prompt, "Test task") {
|
||||||
|
t.Error("expected task content in prompt")
|
||||||
|
}
|
||||||
|
if !containsStr(prompt, "agents/coder/CLAUDE.md") {
|
||||||
|
t.Error("expected coder CLAUDE.md reference")
|
||||||
|
}
|
||||||
|
if !containsStr(prompt, "go test") {
|
||||||
|
t.Error("expected test instruction")
|
||||||
|
}
|
||||||
|
if !containsStr(prompt, "report") {
|
||||||
|
t.Error("expected report instruction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReviewPrompt(t *testing.T) {
|
||||||
|
prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), []byte("# Report\nSve ok."))
|
||||||
|
|
||||||
|
if !containsStr(prompt, "T08") {
|
||||||
|
t.Error("expected task ID in review prompt")
|
||||||
|
}
|
||||||
|
if !containsStr(prompt, "Sve ok") {
|
||||||
|
t.Error("expected report content in review prompt")
|
||||||
|
}
|
||||||
|
if !containsStr(prompt, "agents/checker/CLAUDE.md") {
|
||||||
|
t.Error("expected checker CLAUDE.md reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReviewPrompt_NoReport(t *testing.T) {
|
||||||
|
prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), nil)
|
||||||
|
|
||||||
|
if !containsStr(prompt, "T08") {
|
||||||
|
t.Error("expected task ID")
|
||||||
|
}
|
||||||
|
if containsStr(prompt, "Izveštaj agenta") {
|
||||||
|
t.Error("should not include report section when no report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewTask_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T99/review", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasKillButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "killSession") {
|
||||||
|
t.Error("expected killSession function in console page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_HasProveriButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Put T08 in review
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T08", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Proveri") {
|
||||||
|
t.Error("expected 'Proveri' button for review task")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "/review") {
|
||||||
|
t.Error("expected /review endpoint in Proveri button")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_ToolbarAbovePanels(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// Toolbar should appear before console-panels in the HTML
|
||||||
|
toolbarIdx := strings.Index(body, "console-toolbar")
|
||||||
|
panelsIdx := strings.Index(body, "console-panels")
|
||||||
|
|
||||||
|
if toolbarIdx == -1 {
|
||||||
|
t.Fatal("expected console-toolbar in console page")
|
||||||
|
}
|
||||||
|
if panelsIdx == -1 {
|
||||||
|
t.Fatal("expected console-panels in console page")
|
||||||
|
}
|
||||||
|
if toolbarIdx > panelsIdx {
|
||||||
|
t.Error("expected toolbar before panels in console HTML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasDynamicSessions(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "refreshSessions") {
|
||||||
|
t.Error("expected refreshSessions function in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "/console/sessions") {
|
||||||
|
t.Error("expected /console/sessions API call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasXtermJS(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "xterm.min.js") {
|
||||||
|
t.Error("expected xterm.min.js CDN link in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "addon-fit") {
|
||||||
|
t.Error("expected addon-fit CDN link in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "xterm.css") {
|
||||||
|
t.Error("expected xterm.css CDN link in console page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasWebSocket(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "console/ws/") {
|
||||||
|
t.Error("expected WebSocket URL console/ws/ in console page")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "new WebSocket") {
|
||||||
|
t.Error("expected WebSocket constructor in console page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasEmptyState(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "empty-state") {
|
||||||
|
t.Error("expected empty-state element")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Pusti") {
|
||||||
|
t.Error("expected 'Pusti' instruction in empty state")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "console-terminal") {
|
||||||
|
t.Error("expected console-terminal class in JS code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasBinaryMessageSupport(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "arraybuffer") {
|
||||||
|
t.Error("expected arraybuffer binary type for WebSocket")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Uint8Array") {
|
||||||
|
t.Error("expected Uint8Array handling for binary messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePage_HasResizeHandler(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "fitAddon.fit()") {
|
||||||
|
t.Error("expected fitAddon.fit() for resize handling")
|
||||||
|
}
|
||||||
|
if !containsStr(body, `'resize'`) {
|
||||||
|
t.Error("expected resize message type in WebSocket handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
317
code/internal/server/dashboard_test.go
Normal file
317
code/internal/server/dashboard_test.go
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDashboardHTML(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "KAOS Dashboard") {
|
||||||
|
t.Error("expected 'KAOS Dashboard' in HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "T01") {
|
||||||
|
t.Error("expected T01 in HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "T08") {
|
||||||
|
t.Error("expected T08 in HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "BACKLOG") {
|
||||||
|
t.Error("expected BACKLOG column in HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "DONE") {
|
||||||
|
t.Error("expected DONE column in HTML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMLMoveTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return updated dashboard HTML
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "KAOS Dashboard") {
|
||||||
|
t.Error("expected dashboard HTML after move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_HasAllColumns(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, col := range []string{"BACKLOG", "READY", "ACTIVE", "REVIEW", "DONE"} {
|
||||||
|
if !containsStr(body, col) {
|
||||||
|
t.Errorf("expected %s column in dashboard", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_HasHTMXAttributes(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "hx-get") {
|
||||||
|
t.Error("expected hx-get attributes in HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "hx-target") {
|
||||||
|
t.Error("expected hx-target attributes in HTML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_TasksInCorrectColumns(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// T01 should be in done column, T08 in backlog
|
||||||
|
if !containsStr(body, `id="col-done"`) {
|
||||||
|
t.Error("expected col-done in HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, `id="col-backlog"`) {
|
||||||
|
t.Error("expected col-backlog in HTML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsMoveAllowed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
from, to string
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{"backlog", "ready", true},
|
||||||
|
{"ready", "backlog", true},
|
||||||
|
{"review", "done", true},
|
||||||
|
{"review", "ready", true},
|
||||||
|
{"done", "review", true},
|
||||||
|
{"ready", "active", false},
|
||||||
|
{"active", "review", false},
|
||||||
|
{"backlog", "done", false},
|
||||||
|
{"backlog", "active", false},
|
||||||
|
{"done", "backlog", false},
|
||||||
|
{"ready", "ready", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isMoveAllowed(tt.from, tt.to)
|
||||||
|
if got != tt.allowed {
|
||||||
|
t.Errorf("isMoveAllowed(%s, %s) = %v, want %v", tt.from, tt.to, got, tt.allowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoCacheHeaders(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Dynamic route should have no-cache headers
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
cc := w.Header().Get("Cache-Control")
|
||||||
|
if !containsStr(cc, "no-store") {
|
||||||
|
t.Errorf("expected Cache-Control no-store on dashboard, got %q", cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API route should also have no-cache headers
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w2, req2)
|
||||||
|
|
||||||
|
cc2 := w2.Header().Get("Cache-Control")
|
||||||
|
if !containsStr(cc2, "no-store") {
|
||||||
|
t.Errorf("expected Cache-Control no-store on API, got %q", cc2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardReflectsDiskChanges(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Initial state: T08 in backlog
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var tasks1 []taskResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &tasks1)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, task := range tasks1 {
|
||||||
|
if task.ID == "T08" && task.Status == "backlog" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected T08 in backlog initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move file on disk (simulating external change)
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Second request should reflect the change without server restart
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w2, req2)
|
||||||
|
|
||||||
|
var tasks2 []taskResponse
|
||||||
|
json.Unmarshal(w2.Body.Bytes(), &tasks2)
|
||||||
|
|
||||||
|
found = false
|
||||||
|
for _, task := range tasks2 {
|
||||||
|
if task.ID == "T08" && task.Status == "ready" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected T08 in ready after disk move — server did not read fresh state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_HasSortableScript(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "sortable.min.js") {
|
||||||
|
t.Error("expected sortable.min.js script tag")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "initSortable") {
|
||||||
|
t.Error("expected initSortable function")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_HasDataFolderAttributes(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, `data-folder="backlog"`) {
|
||||||
|
t.Error("expected data-folder attribute on column-tasks")
|
||||||
|
}
|
||||||
|
if !containsStr(body, `data-folder="ready"`) {
|
||||||
|
t.Error("expected data-folder=ready attribute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_HasMoveButtons(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T08 is in backlog, should have "Odobri" button
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T08", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Odobri") {
|
||||||
|
t.Error("expected 'Odobri' button for backlog task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_HasRunButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Move T08 to ready to test run button
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Pusti") {
|
||||||
|
t.Error("expected 'Pusti' button for ready task")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "btn-run") {
|
||||||
|
t.Error("expected btn-run class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_BlockedButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T08 in backlog with dep T07 not in done → blocked
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Blokiran") {
|
||||||
|
t.Error("expected 'Blokiran' for backlog task with unmet deps")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "btn-blocked") {
|
||||||
|
t.Error("expected btn-blocked class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHTML_DoneReportButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// T01 is in done → should have "Izvestaj" button
|
||||||
|
if !containsStr(body, "btn-report") {
|
||||||
|
t.Error("expected btn-report for done task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_HasPrijavaNav(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, `href="/submit"`) {
|
||||||
|
t.Error("expected Prijava nav link in dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
223
code/internal/server/docs.go
Normal file
223
code/internal/server/docs.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
htmltpl "html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// docFile represents a markdown file in the file listing.
|
||||||
|
type docFile struct {
|
||||||
|
Path string // relative path from project root
|
||||||
|
Name string // display name
|
||||||
|
}
|
||||||
|
|
||||||
|
// docsListData holds data for the docs listing page.
|
||||||
|
type docsListData struct {
|
||||||
|
Files []docFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// docsViewData holds data for viewing a single doc.
|
||||||
|
type docsViewData struct {
|
||||||
|
Path string
|
||||||
|
Breadcrumbs []breadcrumb
|
||||||
|
HTML htmltpl.HTML
|
||||||
|
Files []docFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// breadcrumb represents one segment of the navigation path.
|
||||||
|
type breadcrumb struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// md is the goldmark markdown renderer.
|
||||||
|
var md = goldmark.New(
|
||||||
|
goldmark.WithExtensions(extension.Table, extension.Strikethrough),
|
||||||
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// projectRoot returns the KAOS project root derived from TasksDir.
|
||||||
|
func (s *Server) projectRoot() string {
|
||||||
|
return filepath.Dir(s.Config.TasksDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDocsList serves the docs listing page.
|
||||||
|
func (s *Server) handleDocsList(c *gin.Context) {
|
||||||
|
root := s.projectRoot()
|
||||||
|
files := scanMarkdownFiles(root)
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, renderDocsList(docsListData{Files: files}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDocsView serves a rendered markdown file.
|
||||||
|
func (s *Server) handleDocsView(c *gin.Context) {
|
||||||
|
relPath := c.Param("path")
|
||||||
|
// Strip leading slash from wildcard param
|
||||||
|
relPath = strings.TrimPrefix(relPath, "/")
|
||||||
|
|
||||||
|
root := s.projectRoot()
|
||||||
|
|
||||||
|
// Security: only .md files
|
||||||
|
if !strings.HasSuffix(relPath, ".md") {
|
||||||
|
c.String(http.StatusForbidden, "Samo .md fajlovi su dozvoljeni")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: resolve and check path traversal
|
||||||
|
absPath := filepath.Join(root, relPath)
|
||||||
|
absPath = filepath.Clean(absPath)
|
||||||
|
if !strings.HasPrefix(absPath, filepath.Clean(root)+string(filepath.Separator)) {
|
||||||
|
c.String(http.StatusForbidden, "Pristup odbijen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "Fajl nije pronađen: %s", relPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown to HTML
|
||||||
|
rendered := renderMarkdown(content, relPath)
|
||||||
|
|
||||||
|
// HTMX request → return just the content fragment
|
||||||
|
if c.GetHeader("HX-Request") == "true" {
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
breadcrumbHTML := `<div class="docs-breadcrumbs"><a href="/docs">Dokumenti</a>`
|
||||||
|
for _, bc := range buildBreadcrumbs(relPath) {
|
||||||
|
breadcrumbHTML += ` <span class="breadcrumb-sep">›</span> <a href="/docs/` + bc.Path + `">` + bc.Name + `</a>`
|
||||||
|
}
|
||||||
|
breadcrumbHTML += `</div>`
|
||||||
|
c.String(http.StatusOK, breadcrumbHTML+rendered)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full page request → return with sidebar
|
||||||
|
data := docsViewData{
|
||||||
|
Path: relPath,
|
||||||
|
Breadcrumbs: buildBreadcrumbs(relPath),
|
||||||
|
HTML: htmltpl.HTML(rendered),
|
||||||
|
Files: scanMarkdownFiles(s.projectRoot()),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, renderDocsView(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanMarkdownFiles walks the project root and returns all .md files.
|
||||||
|
func scanMarkdownFiles(root string) []docFile {
|
||||||
|
var files []docFile
|
||||||
|
|
||||||
|
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden dirs, code/vendor, .git
|
||||||
|
name := info.Name()
|
||||||
|
if info.IsDir() {
|
||||||
|
if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(name, ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(root, path)
|
||||||
|
files = append(files, docFile{
|
||||||
|
Path: rel,
|
||||||
|
Name: rel,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMarkdown converts markdown bytes to HTML, rewriting relative .md links to /docs/ links.
|
||||||
|
func renderMarkdown(source []byte, docPath string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := md.Convert(source, &buf); err != nil {
|
||||||
|
return "<p>Greška pri renderovanju: " + err.Error() + "</p>"
|
||||||
|
}
|
||||||
|
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Rewrite relative .md links to /docs/ links
|
||||||
|
// Find href="something.md" or href="path/to/file.md" patterns
|
||||||
|
html = rewriteMarkdownLinks(html, docPath)
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteMarkdownLinks converts relative .md hrefs to /docs/ prefixed paths.
|
||||||
|
func rewriteMarkdownLinks(htmlContent, currentPath string) string {
|
||||||
|
dir := filepath.Dir(currentPath)
|
||||||
|
return rewriteLinksSimple(htmlContent, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteLinksSimple does a single pass to rewrite .md links.
|
||||||
|
func rewriteLinksSimple(htmlContent, baseDir string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
remaining := htmlContent
|
||||||
|
|
||||||
|
for {
|
||||||
|
idx := strings.Index(remaining, `href="`)
|
||||||
|
if idx == -1 {
|
||||||
|
result.WriteString(remaining)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteString(remaining[:idx+6])
|
||||||
|
remaining = remaining[idx+6:]
|
||||||
|
|
||||||
|
endQuote := strings.Index(remaining, `"`)
|
||||||
|
if endQuote == -1 {
|
||||||
|
result.WriteString(remaining)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
href := remaining[:endQuote]
|
||||||
|
remaining = remaining[endQuote:]
|
||||||
|
|
||||||
|
if strings.HasSuffix(href, ".md") && !strings.HasPrefix(href, "http") && !strings.HasPrefix(href, "/") {
|
||||||
|
if baseDir == "." || baseDir == "" {
|
||||||
|
result.WriteString("/docs/" + href)
|
||||||
|
} else {
|
||||||
|
result.WriteString("/docs/" + filepath.Join(baseDir, href))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.WriteString(href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBreadcrumbs creates navigation breadcrumbs from a file path.
|
||||||
|
func buildBreadcrumbs(path string) []breadcrumb {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
crumbs := make([]breadcrumb, len(parts))
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
crumbs[i] = breadcrumb{
|
||||||
|
Name: part,
|
||||||
|
Path: strings.Join(parts[:i+1], "/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs
|
||||||
|
}
|
||||||
201
code/internal/server/docs_test.go
Normal file
201
code/internal/server/docs_test.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocsList(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "CLAUDE.md") {
|
||||||
|
t.Error("expected CLAUDE.md in docs list")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "README.md") {
|
||||||
|
t.Error("expected README.md in docs list")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "agents/coder/CLAUDE.md") {
|
||||||
|
t.Error("expected agents/coder/CLAUDE.md in docs list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_CLAUDE(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Glavni fajl") {
|
||||||
|
t.Error("expected rendered markdown content")
|
||||||
|
}
|
||||||
|
// Should have table rendered as HTML
|
||||||
|
if !containsStr(body, "<table>") || !containsStr(body, "<th>") {
|
||||||
|
t.Error("expected HTML table from markdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_NestedFile(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Coder Agent") {
|
||||||
|
t.Error("expected nested file content")
|
||||||
|
}
|
||||||
|
// Breadcrumbs
|
||||||
|
if !containsStr(body, "agents") {
|
||||||
|
t.Error("expected breadcrumb for agents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_PathTraversal(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/../../etc/passwd", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 for path traversal, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_NonMarkdown(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/main.go", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 for non-.md file, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/nonexistent.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_HasBreadcrumbs(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Dokumenti") {
|
||||||
|
t.Error("expected 'Dokumenti' in breadcrumbs")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "coder") {
|
||||||
|
t.Error("expected 'coder' in breadcrumbs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_HasSidebarLayout(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "docs-layout") {
|
||||||
|
t.Error("expected docs-layout class for grid layout")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "docs-sidebar") {
|
||||||
|
t.Error("expected docs-sidebar class")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "docs-main") {
|
||||||
|
t.Error("expected docs-main class")
|
||||||
|
}
|
||||||
|
// Sidebar should list files
|
||||||
|
if !containsStr(body, "README.md") {
|
||||||
|
t.Error("expected file list in sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsView_HTMXReturnsFragment(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// Should NOT have full page HTML
|
||||||
|
if containsStr(body, "<!DOCTYPE html>") {
|
||||||
|
t.Error("HTMX request should return fragment, not full page")
|
||||||
|
}
|
||||||
|
// Should have breadcrumbs and content
|
||||||
|
if !containsStr(body, "Dokumenti") {
|
||||||
|
t.Error("expected breadcrumbs in fragment")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Glavni fajl") {
|
||||||
|
t.Error("expected rendered content in fragment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsList_HasSidebarLayout(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "docs-layout") {
|
||||||
|
t.Error("expected docs-layout class on docs list page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteLinksSimple(t *testing.T) {
|
||||||
|
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
|
||||||
|
result := rewriteLinksSimple(input, ".")
|
||||||
|
if !containsStr(result, `/docs/README.md`) {
|
||||||
|
t.Errorf("expected rewritten link, got: %s", result)
|
||||||
|
}
|
||||||
|
if !containsStr(result, `https://example.com`) {
|
||||||
|
t.Error("external link should not be rewritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteLinksSimple_NestedDir(t *testing.T) {
|
||||||
|
input := `<a href="CLAUDE.md">link</a>`
|
||||||
|
result := rewriteLinksSimple(input, "agents/coder")
|
||||||
|
if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) {
|
||||||
|
t.Errorf("expected nested rewritten link, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
code/internal/server/events.go
Normal file
153
code/internal/server/events.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventBroker manages SSE connections and state change detection.
|
||||||
|
type eventBroker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[chan string]bool
|
||||||
|
lastHash string
|
||||||
|
tasksDir string
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEventBroker creates a new SSE event broker.
|
||||||
|
func newEventBroker(tasksDir string) *eventBroker {
|
||||||
|
return &eventBroker{
|
||||||
|
clients: make(map[chan string]bool),
|
||||||
|
tasksDir: tasksDir,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe adds a client to receive events.
|
||||||
|
func (eb *eventBroker) subscribe() chan string {
|
||||||
|
ch := make(chan string, 10)
|
||||||
|
eb.mu.Lock()
|
||||||
|
eb.clients[ch] = true
|
||||||
|
eb.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsubscribe removes a client.
|
||||||
|
func (eb *eventBroker) unsubscribe(ch chan string) {
|
||||||
|
eb.mu.Lock()
|
||||||
|
delete(eb.clients, ch)
|
||||||
|
close(ch)
|
||||||
|
eb.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast sends an event to all connected clients.
|
||||||
|
func (eb *eventBroker) broadcast(data string) {
|
||||||
|
eb.mu.RLock()
|
||||||
|
defer eb.mu.RUnlock()
|
||||||
|
for ch := range eb.clients {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default:
|
||||||
|
// Skip slow clients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasClients returns true if there are active listeners.
|
||||||
|
func (eb *eventBroker) hasClients() bool {
|
||||||
|
eb.mu.RLock()
|
||||||
|
defer eb.mu.RUnlock()
|
||||||
|
return len(eb.clients) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPolling begins the 2-second polling loop for state changes.
|
||||||
|
func (eb *eventBroker) startPolling(renderFn func() string) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-eb.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if !eb.hasClients() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eb.checkAndBroadcast(renderFn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndBroadcast checks for state changes and broadcasts if changed.
|
||||||
|
func (eb *eventBroker) checkAndBroadcast(renderFn func() string) {
|
||||||
|
hash := eb.computeHash()
|
||||||
|
eb.mu.Lock()
|
||||||
|
changed := hash != eb.lastHash
|
||||||
|
eb.lastHash = hash
|
||||||
|
eb.mu.Unlock()
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
html := renderFn()
|
||||||
|
eb.broadcast(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeHash creates a hash of the current task state.
|
||||||
|
func (eb *eventBroker) computeHash() string {
|
||||||
|
tasks, err := supervisor.ScanTasks(eb.tasksDir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hashTaskState(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashTaskState creates a deterministic hash of task IDs and their statuses.
|
||||||
|
func hashTaskState(tasks []supervisor.Task) string {
|
||||||
|
pairs := make([]string, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
pairs[i] = t.ID + ":" + t.Status
|
||||||
|
}
|
||||||
|
sort.Strings(pairs)
|
||||||
|
combined := strings.Join(pairs, "|")
|
||||||
|
h := sha256.Sum256([]byte(combined))
|
||||||
|
return fmt.Sprintf("%x", h[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEvents serves the SSE event stream.
|
||||||
|
func (s *Server) handleEvents(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
ch := s.events.subscribe()
|
||||||
|
defer s.events.unsubscribe(ch)
|
||||||
|
|
||||||
|
notify := c.Request.Context().Done()
|
||||||
|
|
||||||
|
// Send initial keepalive
|
||||||
|
fmt.Fprintf(c.Writer, ": keepalive\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-notify:
|
||||||
|
return
|
||||||
|
case data, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.Writer, "event: taskUpdate\ndata: %s\n\n", data)
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
code/internal/server/logs.go
Normal file
41
code/internal/server/logs.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxLogLines = 20
|
||||||
|
|
||||||
|
// handleLogsTail returns the last 20 lines of the server log file as plain text.
|
||||||
|
func (s *Server) handleLogsTail(c *gin.Context) {
|
||||||
|
if s.Config.LogFile == "" {
|
||||||
|
c.String(http.StatusOK, "KAOS_LOG_FILE nije podešen. Podesi env varijablu za prikaz logova.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(s.Config.LogFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.String(http.StatusOK, "Log fajl ne postoji: "+s.Config.LogFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.String(http.StatusInternalServerError, "Greška pri čitanju loga: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := tailLines(string(data), maxLogLines)
|
||||||
|
c.String(http.StatusOK, strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailLines returns the last n non-empty lines from text.
|
||||||
|
func tailLines(text string, n int) []string {
|
||||||
|
allLines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||||
|
if len(allLines) <= n {
|
||||||
|
return allLines
|
||||||
|
}
|
||||||
|
return allLines[len(allLines)-n:]
|
||||||
|
}
|
||||||
161
code/internal/server/logs_test.go
Normal file
161
code/internal/server/logs_test.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleLogsTail_OK(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
logFile := filepath.Join(t.TempDir(), "test.log")
|
||||||
|
lines := make([]string, 25)
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = "linija " + string(rune('A'+i))
|
||||||
|
}
|
||||||
|
os.WriteFile(logFile, []byte(strings.Join(lines, "\n")+"\n"), 0644)
|
||||||
|
srv.Config.LogFile = logFile
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/logs/tail", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n")
|
||||||
|
if len(got) != maxLogLines {
|
||||||
|
t.Fatalf("expected %d lines, got %d", maxLogLines, len(got))
|
||||||
|
}
|
||||||
|
// First returned line should be line 6 (index 5) since we have 25 lines, tail 20
|
||||||
|
if got[0] != "linija F" {
|
||||||
|
t.Errorf("expected first line 'linija F', got %q", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogsTail_LessThan20Lines(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
logFile := filepath.Join(t.TempDir(), "test.log")
|
||||||
|
os.WriteFile(logFile, []byte("prva\ndruga\ntreca\n"), 0644)
|
||||||
|
srv.Config.LogFile = logFile
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/logs/tail", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n")
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("expected 3 lines, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogsTail_NoLogFile(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
// LogFile is empty string by default in test config
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/logs/tail", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(w.Body.String(), "KAOS_LOG_FILE") {
|
||||||
|
t.Error("expected message about KAOS_LOG_FILE not being set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogsTail_FileNotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
srv.Config.LogFile = "/tmp/nonexistent-kaos-test-log-12345.log"
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/logs/tail", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(w.Body.String(), "ne postoji") {
|
||||||
|
t.Error("expected message about file not existing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogsTail_Max20Lines(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
logFile := filepath.Join(t.TempDir(), "test.log")
|
||||||
|
lines := make([]string, 100)
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = "log line"
|
||||||
|
}
|
||||||
|
os.WriteFile(logFile, []byte(strings.Join(lines, "\n")+"\n"), 0644)
|
||||||
|
srv.Config.LogFile = logFile
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/logs/tail", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n")
|
||||||
|
if len(got) > maxLogLines {
|
||||||
|
t.Errorf("expected max %d lines, got %d", maxLogLines, len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailLines(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
n int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"empty", "", 20, 1},
|
||||||
|
{"less than n", "a\nb\nc", 20, 3},
|
||||||
|
{"exact n", "a\nb\nc", 3, 3},
|
||||||
|
{"more than n", "a\nb\nc\nd\ne", 3, 3},
|
||||||
|
{"trailing newline", "a\nb\nc\n", 2, 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tailLines(tt.text, tt.n)
|
||||||
|
if len(got) != tt.expected {
|
||||||
|
t.Errorf("tailLines(%q, %d) = %d lines, want %d", tt.text, tt.n, len(got), tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailLines_Content(t *testing.T) {
|
||||||
|
text := "first\nsecond\nthird\nfourth\nfifth\n"
|
||||||
|
got := tailLines(text, 3)
|
||||||
|
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("expected 3 lines, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0] != "third" {
|
||||||
|
t.Errorf("expected 'third', got %q", got[0])
|
||||||
|
}
|
||||||
|
if got[1] != "fourth" {
|
||||||
|
t.Errorf("expected 'fourth', got %q", got[1])
|
||||||
|
}
|
||||||
|
if got[2] != "fifth" {
|
||||||
|
t.Errorf("expected 'fifth', got %q", got[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
71
code/internal/server/pty.go
Normal file
71
code/internal/server/pty.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ansiRegex matches ANSI escape sequences for stripping terminal formatting.
|
||||||
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\[[\?0-9;]*[a-zA-Z]`)
|
||||||
|
|
||||||
|
// stripAnsi removes ANSI escape codes from a string.
|
||||||
|
func stripAnsi(s string) string {
|
||||||
|
return ansiRegex.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPTY starts a command in a pseudo-terminal and returns the PTY master fd.
|
||||||
|
// The caller is responsible for closing the returned *os.File.
|
||||||
|
func startPTY(cmd *exec.Cmd) (*os.File, error) {
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ptmx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPTY reads from a PTY master and calls sendLine for each chunk of text.
|
||||||
|
// It splits on newlines so each SSE event is one line.
|
||||||
|
func readPTY(ptmx io.Reader, sendLine func(string)) {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
var partial string
|
||||||
|
for {
|
||||||
|
n, err := ptmx.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
text := partial + stripAnsi(string(buf[:n]))
|
||||||
|
partial = ""
|
||||||
|
|
||||||
|
// Split into lines, keep partial for next read
|
||||||
|
for {
|
||||||
|
idx := -1
|
||||||
|
for i, b := range []byte(text) {
|
||||||
|
if b == '\n' || b == '\r' {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
partial = text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line := text[:idx]
|
||||||
|
// Skip empty lines from \r\n sequences
|
||||||
|
if line != "" {
|
||||||
|
sendLine(line)
|
||||||
|
}
|
||||||
|
// Skip past the newline character(s)
|
||||||
|
text = text[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Send remaining partial text
|
||||||
|
if partial != "" {
|
||||||
|
sendLine(partial)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
code/internal/server/pty_session.go
Normal file
253
code/internal/server/pty_session.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outputBufferSize = 1024 * 1024 // 1MB ring buffer for replay
|
||||||
|
)
|
||||||
|
|
||||||
|
// RingBuffer is a fixed-size circular buffer for terminal output.
|
||||||
|
type RingBuffer struct {
|
||||||
|
data []byte
|
||||||
|
size int
|
||||||
|
pos int
|
||||||
|
full bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRingBuffer creates a new ring buffer with the given size.
|
||||||
|
func NewRingBuffer(size int) *RingBuffer {
|
||||||
|
return &RingBuffer{data: make([]byte, size), size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write appends data to the ring buffer.
|
||||||
|
func (rb *RingBuffer) Write(p []byte) {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
for _, b := range p {
|
||||||
|
rb.data[rb.pos] = b
|
||||||
|
rb.pos++
|
||||||
|
if rb.pos >= rb.size {
|
||||||
|
rb.pos = 0
|
||||||
|
rb.full = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns the buffer contents in correct order.
|
||||||
|
func (rb *RingBuffer) Bytes() []byte {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
if !rb.full {
|
||||||
|
result := make([]byte, rb.pos)
|
||||||
|
copy(result, rb.data[:rb.pos])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result := make([]byte, rb.size)
|
||||||
|
n := copy(result, rb.data[rb.pos:])
|
||||||
|
copy(result[n:], rb.data[:rb.pos])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the buffer.
|
||||||
|
func (rb *RingBuffer) Reset() {
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
rb.pos = 0
|
||||||
|
rb.full = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// consolePTYSession manages a single claude CLI running in a pseudo-terminal.
|
||||||
|
type consolePTYSession struct {
|
||||||
|
ID string
|
||||||
|
Ptmx *os.File
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
buffer *RingBuffer
|
||||||
|
subscribers map[string]chan []byte
|
||||||
|
mu sync.Mutex
|
||||||
|
done chan struct{}
|
||||||
|
lastActive time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnTaskPTY starts an interactive claude CLI with auto-permissions in a PTY.
|
||||||
|
// Used for task work and review sessions. The prompt is sent after startup.
|
||||||
|
func spawnTaskPTY(projectDir string) (*consolePTYSession, error) {
|
||||||
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk")
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
cmd.Env = cleanEnvForPTY()
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 180})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start pty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &consolePTYSession{
|
||||||
|
ID: fmt.Sprintf("task-%d", time.Now().UnixNano()),
|
||||||
|
Ptmx: ptmx,
|
||||||
|
Cmd: cmd,
|
||||||
|
buffer: NewRingBuffer(outputBufferSize),
|
||||||
|
subscribers: make(map[string]chan []byte),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
lastActive: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go sess.readLoop()
|
||||||
|
go sess.waitExit()
|
||||||
|
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnShellPTY starts an interactive claude CLI in a PTY for the console.
|
||||||
|
func spawnShellPTY(projectDir string) (*consolePTYSession, error) {
|
||||||
|
cmd := exec.Command("claude")
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
cmd.Env = cleanEnvForPTY()
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start pty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &consolePTYSession{
|
||||||
|
ID: fmt.Sprintf("shell-%d", time.Now().UnixNano()),
|
||||||
|
Ptmx: ptmx,
|
||||||
|
Cmd: cmd,
|
||||||
|
buffer: NewRingBuffer(outputBufferSize),
|
||||||
|
subscribers: make(map[string]chan []byte),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
lastActive: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go sess.readLoop()
|
||||||
|
go sess.waitExit()
|
||||||
|
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop reads PTY output, writes to ring buffer, and forwards to subscribers.
|
||||||
|
func (s *consolePTYSession) readLoop() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
totalBytes := 0
|
||||||
|
for {
|
||||||
|
n, err := s.Ptmx.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("PTY[%s]: readLoop ended (read %d bytes total, err: %v)", s.ID, totalBytes, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalBytes += n
|
||||||
|
data := make([]byte, n)
|
||||||
|
copy(data, buf[:n])
|
||||||
|
|
||||||
|
s.buffer.Write(data)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
subs := len(s.subscribers)
|
||||||
|
for _, ch := range s.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if totalBytes == n {
|
||||||
|
// First chunk — log it
|
||||||
|
log.Printf("PTY[%s]: first output (%d bytes, %d subscribers)", s.ID, n, subs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitExit waits for the CLI process to exit and signals done.
|
||||||
|
func (s *consolePTYSession) waitExit() {
|
||||||
|
if s.Cmd.Process != nil {
|
||||||
|
s.Cmd.Wait()
|
||||||
|
}
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe adds a subscriber for PTY output.
|
||||||
|
func (s *consolePTYSession) Subscribe(id string) chan []byte {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
ch := make(chan []byte, 256)
|
||||||
|
s.subscribers[id] = ch
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a subscriber.
|
||||||
|
func (s *consolePTYSession) Unsubscribe(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if ch, ok := s.subscribers[id]; ok {
|
||||||
|
close(ch)
|
||||||
|
delete(s.subscribers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize changes the PTY terminal size.
|
||||||
|
func (s *consolePTYSession) Resize(rows, cols uint16) error {
|
||||||
|
return pty.Setsize(s.Ptmx, &pty.Winsize{Rows: rows, Cols: cols})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteInput sends keyboard input to the PTY.
|
||||||
|
func (s *consolePTYSession) WriteInput(data []byte) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
return s.Ptmx.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuffer returns the ring buffer contents for replay.
|
||||||
|
func (s *consolePTYSession) GetBuffer() []byte {
|
||||||
|
return s.buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that closes when the process exits.
|
||||||
|
func (s *consolePTYSession) Done() <-chan struct{} {
|
||||||
|
return s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the PTY session.
|
||||||
|
func (s *consolePTYSession) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
for id, ch := range s.subscribers {
|
||||||
|
close(ch)
|
||||||
|
delete(s.subscribers, id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.Ptmx.Close()
|
||||||
|
if s.Cmd.Process != nil {
|
||||||
|
s.Cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEnvForPTY returns environment with proper terminal settings.
|
||||||
|
func cleanEnvForPTY() []string {
|
||||||
|
var env []string
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
if strings.HasPrefix(e, "CLAUDECODE=") ||
|
||||||
|
strings.HasPrefix(e, "CLAUDE_CODE_ENTRYPOINT=") ||
|
||||||
|
strings.HasPrefix(e, "TERM=") ||
|
||||||
|
strings.HasPrefix(e, "COLORTERM=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env = append(env, e)
|
||||||
|
}
|
||||||
|
env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor")
|
||||||
|
return env
|
||||||
|
}
|
||||||
@ -9,13 +9,20 @@ import (
|
|||||||
"github.com/dal/kaos/web"
|
"github.com/dal/kaos/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// taskCardData wraps a task with UI display info.
|
||||||
|
type taskCardData struct {
|
||||||
|
supervisor.Task
|
||||||
|
CanRun bool
|
||||||
|
Action string // blocked, review, run, running, question, approve, done
|
||||||
|
}
|
||||||
|
|
||||||
// columnData holds data for rendering a single kanban column.
|
// columnData holds data for rendering a single kanban column.
|
||||||
type columnData struct {
|
type columnData struct {
|
||||||
Name string
|
Name string
|
||||||
Label string
|
Label string
|
||||||
Icon string
|
Icon string
|
||||||
Count int
|
Count int
|
||||||
Tasks []supervisor.Task
|
Tasks []taskCardData
|
||||||
}
|
}
|
||||||
|
|
||||||
// dashboardData holds data for the full dashboard page.
|
// dashboardData holds data for the full dashboard page.
|
||||||
@ -26,7 +33,7 @@ type dashboardData struct {
|
|||||||
// taskDetailData holds data for the task detail panel.
|
// taskDetailData holds data for the task detail panel.
|
||||||
type taskDetailData struct {
|
type taskDetailData struct {
|
||||||
Task supervisor.Task
|
Task supervisor.Task
|
||||||
Content string
|
Content template.HTML
|
||||||
HasReport bool
|
HasReport bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,27 +65,28 @@ func init() {
|
|||||||
web.TemplatesFS,
|
web.TemplatesFS,
|
||||||
"templates/layout.html",
|
"templates/layout.html",
|
||||||
"templates/dashboard.html",
|
"templates/dashboard.html",
|
||||||
|
"templates/docs-list.html",
|
||||||
|
"templates/docs-view.html",
|
||||||
|
"templates/console.html",
|
||||||
|
"templates/submit.html",
|
||||||
"templates/partials/column.html",
|
"templates/partials/column.html",
|
||||||
"templates/partials/task-card.html",
|
"templates/partials/task-card.html",
|
||||||
"templates/partials/task-detail.html",
|
"templates/partials/task-detail.html",
|
||||||
|
"templates/partials/search-results.html",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDashboard generates the full dashboard HTML page.
|
// renderDashboard generates the full dashboard HTML page.
|
||||||
func renderDashboard(columns map[string][]supervisor.Task) string {
|
func renderDashboard(columns map[string][]supervisor.Task) string {
|
||||||
data := dashboardData{}
|
// Build set of done task IDs for dependency checking
|
||||||
for _, col := range columnOrder {
|
doneSet := make(map[string]bool)
|
||||||
tasks := columns[col]
|
for _, t := range columns["done"] {
|
||||||
data.Columns = append(data.Columns, columnData{
|
doneSet[t.ID] = true
|
||||||
Name: col,
|
|
||||||
Label: strings.ToUpper(col),
|
|
||||||
Icon: statusIcons[col],
|
|
||||||
Count: len(tasks),
|
|
||||||
Tasks: tasks,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data := buildDashboardData(columns, doneSet)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := templates.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
if err := templates.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||||
return "Greška pri renderovanju: " + err.Error()
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
@ -86,11 +94,132 @@ func renderDashboard(columns map[string][]supervisor.Task) string {
|
|||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildDashboardData creates dashboard data with task actions resolved.
|
||||||
|
func buildDashboardData(columns map[string][]supervisor.Task, doneSet map[string]bool) dashboardData {
|
||||||
|
data := dashboardData{}
|
||||||
|
for _, col := range columnOrder {
|
||||||
|
tasks := columns[col]
|
||||||
|
cards := make([]taskCardData, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
action := resolveTaskAction(t, doneSet)
|
||||||
|
cards[i] = taskCardData{
|
||||||
|
Task: t,
|
||||||
|
CanRun: action == "run",
|
||||||
|
Action: action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.Columns = append(data.Columns, columnData{
|
||||||
|
Name: col,
|
||||||
|
Label: strings.ToUpper(col),
|
||||||
|
Icon: statusIcons[col],
|
||||||
|
Count: len(tasks),
|
||||||
|
Tasks: cards,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBoardFragment generates only the board HTML for SSE updates.
|
||||||
|
func renderBoardFragment(columns map[string][]supervisor.Task) string {
|
||||||
|
doneSet := make(map[string]bool)
|
||||||
|
for _, t := range columns["done"] {
|
||||||
|
doneSet[t.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
data := buildDashboardData(columns, doneSet)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "content", data); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTaskAction determines which action button to show for a task.
|
||||||
|
func resolveTaskAction(t supervisor.Task, doneSet map[string]bool) string {
|
||||||
|
switch t.Status {
|
||||||
|
case "backlog":
|
||||||
|
for _, dep := range t.DependsOn {
|
||||||
|
if !doneSet[dep] {
|
||||||
|
return "blocked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "review" // deps met, needs operator review before ready
|
||||||
|
case "ready":
|
||||||
|
return "run"
|
||||||
|
case "active":
|
||||||
|
return "running"
|
||||||
|
case "review":
|
||||||
|
return "approve"
|
||||||
|
case "done":
|
||||||
|
return "done"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDocsList generates the docs listing HTML page.
|
||||||
|
func renderDocsList(data docsListData) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "docs-list", data); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDocsView generates the docs view HTML page.
|
||||||
|
func renderDocsView(data docsViewData) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "docs-view", data); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderConsolePage generates the console HTML page.
|
||||||
|
func renderConsolePage() string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "console", nil); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSubmitPage generates the submit page HTML.
|
||||||
|
func renderSubmitPage() string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "submit", nil); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSearchResults generates the search results HTML fragment.
|
||||||
|
func renderSearchResults(data searchResultsData) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "search-results", data); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderReportModal generates HTML fragment for the report overlay modal.
|
||||||
|
func renderReportModal(taskID, title, content string) string {
|
||||||
|
rendered := renderMarkdown([]byte(content), taskID+"-report.md")
|
||||||
|
return `<div class="detail-inner">
|
||||||
|
<span class="detail-close" onclick="closeDetail()">✕</span>
|
||||||
|
<h2>` + template.HTMLEscapeString(title) + `</h2>
|
||||||
|
<div class="detail-content docs-content">` + rendered + `</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
// renderTaskDetail generates HTML fragment for task detail panel.
|
// renderTaskDetail generates HTML fragment for task detail panel.
|
||||||
|
// Content is rendered from markdown to HTML using goldmark.
|
||||||
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
||||||
|
rendered := renderMarkdown([]byte(content), t.ID+".md")
|
||||||
data := taskDetailData{
|
data := taskDetailData{
|
||||||
Task: t,
|
Task: t,
|
||||||
Content: content,
|
Content: template.HTML(rendered),
|
||||||
HasReport: hasReport,
|
HasReport: hasReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
191
code/internal/server/search.go
Normal file
191
code/internal/server/search.go
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// searchResult represents a single search result.
|
||||||
|
type searchResult struct {
|
||||||
|
Type string // "task", "doc", "report"
|
||||||
|
Icon string // emoji icon
|
||||||
|
Title string // display title
|
||||||
|
Link string // URL to navigate to
|
||||||
|
Snippet string // context around the match
|
||||||
|
Status string // task status (only for tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchResultsData holds data for the search results template.
|
||||||
|
type searchResultsData struct {
|
||||||
|
Query string
|
||||||
|
Results []searchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearch serves search results as an HTML fragment.
|
||||||
|
func (s *Server) handleSearch(c *gin.Context) {
|
||||||
|
query := strings.TrimSpace(c.Query("q"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
c.String(http.StatusOK, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root := s.projectRoot()
|
||||||
|
var results []searchResult
|
||||||
|
|
||||||
|
// 1. Search tasks
|
||||||
|
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
|
||||||
|
for _, t := range tasks {
|
||||||
|
if matchTask(t, query) {
|
||||||
|
snippet := taskSnippet(t, query)
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Type: "task",
|
||||||
|
Icon: "📋",
|
||||||
|
Title: t.ID + ": " + t.Title,
|
||||||
|
Link: "/task/" + t.ID,
|
||||||
|
Snippet: snippet,
|
||||||
|
Status: t.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search documents (non-report .md files)
|
||||||
|
docs := scanMarkdownFiles(root)
|
||||||
|
for _, doc := range docs {
|
||||||
|
// Skip task files and reports (handled separately)
|
||||||
|
if strings.HasPrefix(doc.Path, "TASKS/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(root, doc.Path))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsInsensitive(string(content), query) || containsInsensitive(doc.Path, query) {
|
||||||
|
snippet := extractSnippet(string(content), query)
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Type: "doc",
|
||||||
|
Icon: "📄",
|
||||||
|
Title: doc.Path,
|
||||||
|
Link: "/docs/" + doc.Path,
|
||||||
|
Snippet: snippet,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Search reports
|
||||||
|
reportsDir := filepath.Join(s.Config.TasksDir, "reports")
|
||||||
|
entries, _ := os.ReadDir(reportsDir)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(reportsDir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsInsensitive(string(content), query) || containsInsensitive(entry.Name(), query) {
|
||||||
|
// Extract task ID from report filename (T01-report.md → T01)
|
||||||
|
taskID := strings.TrimSuffix(entry.Name(), "-report.md")
|
||||||
|
snippet := extractSnippet(string(content), query)
|
||||||
|
results = append(results, searchResult{
|
||||||
|
Type: "report",
|
||||||
|
Icon: "📊",
|
||||||
|
Title: entry.Name(),
|
||||||
|
Link: "/report/" + taskID,
|
||||||
|
Snippet: snippet,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
if len(results) > 20 {
|
||||||
|
results = results[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
data := searchResultsData{
|
||||||
|
Query: query,
|
||||||
|
Results: results,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, renderSearchResults(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchTask checks if a task matches the query (case insensitive).
|
||||||
|
func matchTask(t supervisor.Task, query string) bool {
|
||||||
|
q := strings.ToLower(query)
|
||||||
|
return containsInsensitive(t.ID, query) ||
|
||||||
|
containsInsensitive(t.Title, query) ||
|
||||||
|
containsInsensitive(t.Description, query) ||
|
||||||
|
containsInsensitive(t.Agent, query) ||
|
||||||
|
strings.ToLower(t.Status) == q
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskSnippet returns a snippet from the task for display.
|
||||||
|
func taskSnippet(t supervisor.Task, query string) string {
|
||||||
|
// Try to find match in description first
|
||||||
|
if t.Description != "" && containsInsensitive(t.Description, query) {
|
||||||
|
return extractSnippet(t.Description, query)
|
||||||
|
}
|
||||||
|
// Fall back to description start
|
||||||
|
if t.Description != "" {
|
||||||
|
desc := t.Description
|
||||||
|
if len(desc) > 100 {
|
||||||
|
desc = desc[:100] + "..."
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
return t.Agent + " · " + t.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsInsensitive checks if s contains substr (case insensitive).
|
||||||
|
func containsInsensitive(s, substr string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSnippet extracts a snippet around the first match with context.
|
||||||
|
func extractSnippet(content, query string) string {
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
q := strings.ToLower(query)
|
||||||
|
|
||||||
|
idx := strings.Index(lower, q)
|
||||||
|
if idx == -1 {
|
||||||
|
// Return first 100 chars
|
||||||
|
if len(content) > 100 {
|
||||||
|
return content[:100] + "..."
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get context: 40 chars before and 60 chars after
|
||||||
|
start := idx - 40
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := idx + len(query) + 60
|
||||||
|
if end > len(content) {
|
||||||
|
end = len(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet := content[start:end]
|
||||||
|
|
||||||
|
// Clean up: remove newlines, trim
|
||||||
|
snippet = strings.ReplaceAll(snippet, "\n", " ")
|
||||||
|
snippet = strings.ReplaceAll(snippet, "\r", "")
|
||||||
|
snippet = strings.TrimSpace(snippet)
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
snippet = "..." + snippet
|
||||||
|
}
|
||||||
|
if end < len(content) {
|
||||||
|
snippet = snippet + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippet
|
||||||
|
}
|
||||||
117
code/internal/server/search_test.go
Normal file
117
code/internal/server/search_test.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearch_FindsTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T01") {
|
||||||
|
t.Error("expected T01 in search results for 'Prvi'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsTaskByID(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T08") {
|
||||||
|
t.Error("expected T08 in search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsDocument(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "checker/CLAUDE.md") {
|
||||||
|
t.Error("expected checker CLAUDE.md in search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_FindsReport(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T01-report.md") {
|
||||||
|
t.Error("expected T01 report in search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_EmptyQuery(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.Len() != 0 {
|
||||||
|
t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_NoResults(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Nema rezultata") {
|
||||||
|
t.Error("expected 'Nema rezultata' message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_CaseInsensitive(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "coder") {
|
||||||
|
t.Error("expected case-insensitive match for 'CODER'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_HasSnippet(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "kodiranja") {
|
||||||
|
t.Error("expected snippet with 'kodiranja' text")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,15 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@ -17,8 +21,12 @@ import (
|
|||||||
|
|
||||||
// Server holds the HTTP server state.
|
// Server holds the HTTP server state.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Router *gin.Engine
|
Router *gin.Engine
|
||||||
|
console *taskSessionManager
|
||||||
|
events *eventBroker
|
||||||
|
chatMu sync.RWMutex
|
||||||
|
chats map[string]*chatState
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskResponse is the JSON representation of a task.
|
// taskResponse is the JSON representation of a task.
|
||||||
@ -64,8 +72,11 @@ func New(cfg *config.Config) *Server {
|
|||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Router: router,
|
Router: router,
|
||||||
|
console: newTaskSessionManager(),
|
||||||
|
events: newEventBroker(cfg.TasksDir),
|
||||||
|
chats: make(map[string]*chatState),
|
||||||
}
|
}
|
||||||
|
|
||||||
// No caching for dynamic routes — disk is the source of truth.
|
// No caching for dynamic routes — disk is the source of truth.
|
||||||
@ -95,7 +106,34 @@ func (s *Server) setupRoutes() {
|
|||||||
s.Router.GET("/", s.handleDashboard)
|
s.Router.GET("/", s.handleDashboard)
|
||||||
s.Router.GET("/task/:id", s.handleTaskDetail)
|
s.Router.GET("/task/:id", s.handleTaskDetail)
|
||||||
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
||||||
|
s.Router.POST("/task/:id/run", s.handleRunTask)
|
||||||
|
s.Router.POST("/task/:id/review", s.handleReviewTask)
|
||||||
s.Router.GET("/report/:id", s.handleReport)
|
s.Router.GET("/report/:id", s.handleReport)
|
||||||
|
|
||||||
|
// SSE events
|
||||||
|
s.Router.GET("/events", s.handleEvents)
|
||||||
|
|
||||||
|
// Search route
|
||||||
|
s.Router.GET("/search", s.handleSearch)
|
||||||
|
|
||||||
|
// Console routes
|
||||||
|
s.Router.GET("/console", s.handleConsolePage)
|
||||||
|
s.Router.GET("/console/sessions", s.handleConsoleSessions)
|
||||||
|
s.Router.POST("/console/kill/:taskID", s.handleConsoleKill)
|
||||||
|
s.Router.GET("/console/ws/:key", s.handleConsoleWS)
|
||||||
|
|
||||||
|
// Logs route
|
||||||
|
s.Router.GET("/api/logs/tail", s.handleLogsTail)
|
||||||
|
|
||||||
|
// Docs routes
|
||||||
|
s.Router.GET("/docs", s.handleDocsList)
|
||||||
|
s.Router.GET("/docs/*path", s.handleDocsView)
|
||||||
|
|
||||||
|
// Submit routes
|
||||||
|
s.Router.GET("/submit", s.handleSubmitPage)
|
||||||
|
s.Router.POST("/submit/simple", s.handleSimpleSubmit)
|
||||||
|
s.Router.POST("/submit/chat", s.handleChatSubmit)
|
||||||
|
s.Router.GET("/submit/chat/stream/:id", s.handleChatStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiGetTasks returns all tasks as JSON.
|
// apiGetTasks returns all tasks as JSON.
|
||||||
@ -174,6 +212,12 @@ func (s *Server) apiMoveTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append timestamp to moved file
|
||||||
|
newPath := filepath.Join(s.Config.TasksDir, toFolder, id+".md")
|
||||||
|
if label, ok := moveEventLabel[toFolder]; ok {
|
||||||
|
appendTimestamp(newPath, label)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "moved": id, "to": toFolder})
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "moved": id, "to": toFolder})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,27 +288,140 @@ func (s *Server) handleMoveTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append timestamp to moved file
|
||||||
|
newPath := filepath.Join(s.Config.TasksDir, toFolder, id+".md")
|
||||||
|
if label, ok := moveEventLabel[toFolder]; ok {
|
||||||
|
appendTimestamp(newPath, label)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-scan and return updated dashboard
|
// Re-scan and return updated dashboard
|
||||||
s.handleDashboard(c)
|
s.handleDashboard(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleReport serves a task report file.
|
// handleRunTask launches a Claude Code agent for a task in a clean session.
|
||||||
|
func (s *Server) handleRunTask(c *gin.Context) {
|
||||||
|
id := strings.ToUpper(c.Param("id"))
|
||||||
|
|
||||||
|
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task := supervisor.FindTask(tasks, id)
|
||||||
|
if task == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if task.Status == "active" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": id + " je već aktivan"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.Status == "done" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": id + " je već završen"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dependencies for backlog tasks
|
||||||
|
if task.Status == "backlog" {
|
||||||
|
doneSet := make(map[string]bool)
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Status == "done" {
|
||||||
|
doneSet[t.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dep := range task.DependsOn {
|
||||||
|
if !doneSet[dep] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "zavisnost " + dep + " nije ispunjena"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Move backlog → ready first
|
||||||
|
if err := supervisor.MoveTask(s.Config.TasksDir, id, "backlog", "ready"); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readyPath := filepath.Join(s.Config.TasksDir, "ready", id+".md")
|
||||||
|
appendTimestamp(readyPath, "Odobren (→ready)")
|
||||||
|
task.Status = "ready"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move ready → active
|
||||||
|
if err := supervisor.MoveTask(s.Config.TasksDir, id, "ready", "active"); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append "Pokrenut" timestamp
|
||||||
|
taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md")
|
||||||
|
appendTimestamp(taskPath, "Pokrenut (→active)")
|
||||||
|
|
||||||
|
// Read task content and spawn a claude work session
|
||||||
|
taskContent, _ := os.ReadFile(taskPath)
|
||||||
|
prompt := buildWorkPrompt(id, taskContent)
|
||||||
|
|
||||||
|
_, err = s.console.startSession(id, "work", s.projectRoot(), prompt)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: session start failed for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "started",
|
||||||
|
"task": id,
|
||||||
|
"session": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReport serves a task report in the overlay modal.
|
||||||
|
// If a report file exists, render it. Otherwise, show the task content.
|
||||||
func (s *Server) handleReport(c *gin.Context) {
|
func (s *Server) handleReport(c *gin.Context) {
|
||||||
id := strings.ToUpper(c.Param("id"))
|
id := strings.ToUpper(c.Param("id"))
|
||||||
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
|
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
|
||||||
|
|
||||||
content, err := os.ReadFile(reportPath)
|
var content []byte
|
||||||
if err != nil {
|
var title string
|
||||||
c.String(http.StatusNotFound, "Izveštaj za %s nije pronađen", id)
|
|
||||||
return
|
if data, err := os.ReadFile(reportPath); err == nil {
|
||||||
|
content = data
|
||||||
|
title = id + " — Izveštaj"
|
||||||
|
} else {
|
||||||
|
// No report — show the task file itself
|
||||||
|
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Greška: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task := supervisor.FindTask(tasks, id)
|
||||||
|
if task == nil {
|
||||||
|
c.String(http.StatusNotFound, "Task %s nije pronađen", id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(task.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "Fajl nije pronađen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = data
|
||||||
|
title = id + " — " + task.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
c.String(http.StatusOK, string(content))
|
c.String(http.StatusOK, renderReportModal(id, title, string(content)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the HTTP server.
|
// Run starts the HTTP server.
|
||||||
func (s *Server) Run() error {
|
func (s *Server) Run() error {
|
||||||
|
// Start SSE polling for task state changes
|
||||||
|
s.events.startPolling(func() string {
|
||||||
|
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
columns := groupByStatus(tasks)
|
||||||
|
return renderBoardFragment(columns)
|
||||||
|
})
|
||||||
return s.Router.Run(":" + s.Config.Port)
|
return s.Router.Run(":" + s.Config.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,6 +459,40 @@ func isMoveAllowed(from, to string) bool {
|
|||||||
return targets[to]
|
return targets[to]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moveEventLabel returns a human-readable label for a folder transition.
|
||||||
|
var moveEventLabel = map[string]string{
|
||||||
|
"ready": "Odobren (→ready)",
|
||||||
|
"active": "Pokrenut (→active)",
|
||||||
|
"review": "Završen (→review)",
|
||||||
|
"done": "Odobren (→done)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendTimestamp adds a timestamp row to the task file's "## Vremena" table.
|
||||||
|
// If the table doesn't exist yet, it creates it.
|
||||||
|
func appendTimestamp(filePath, event string) error {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Format("2006-01-02 15:04")
|
||||||
|
row := fmt.Sprintf("| %s | %s |", event, now)
|
||||||
|
|
||||||
|
text := string(content)
|
||||||
|
marker := "## Vremena"
|
||||||
|
|
||||||
|
if strings.Contains(text, marker) {
|
||||||
|
// Table exists — append row before the last line break at end
|
||||||
|
text = strings.TrimRight(text, "\n") + "\n" + row + "\n"
|
||||||
|
} else {
|
||||||
|
// Create the table section at the end
|
||||||
|
section := fmt.Sprintf("\n%s\n\n| Događaj | Vreme |\n|---------|-------|\n%s\n", marker, row)
|
||||||
|
text = strings.TrimRight(text, "\n") + "\n" + section
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filePath, []byte(text), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
func toTaskResponse(t supervisor.Task) taskResponse {
|
func toTaskResponse(t supervisor.Task) taskResponse {
|
||||||
deps := t.DependsOn
|
deps := t.DependsOn
|
||||||
if deps == nil {
|
if deps == nil {
|
||||||
|
|||||||
@ -1,562 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/dal/kaos/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testTask1 = `# T01: Prvi task
|
|
||||||
|
|
||||||
**Agent:** coder
|
|
||||||
**Model:** Sonnet
|
|
||||||
**Zavisi od:** —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Opis
|
|
||||||
|
|
||||||
Opis prvog taska.
|
|
||||||
|
|
||||||
---
|
|
||||||
`
|
|
||||||
|
|
||||||
const testTask2 = `# T08: HTTP server
|
|
||||||
|
|
||||||
**Agent:** coder
|
|
||||||
**Model:** Sonnet
|
|
||||||
**Zavisi od:** T07
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Opis
|
|
||||||
|
|
||||||
Implementacija HTTP servera.
|
|
||||||
|
|
||||||
---
|
|
||||||
`
|
|
||||||
|
|
||||||
func setupTestServer(t *testing.T) *Server {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
tasksDir := filepath.Join(dir, "TASKS")
|
|
||||||
for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} {
|
|
||||||
os.MkdirAll(filepath.Join(tasksDir, folder), 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
|
|
||||||
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
TasksDir: tasksDir,
|
|
||||||
ProjectPath: dir,
|
|
||||||
Port: "0",
|
|
||||||
}
|
|
||||||
|
|
||||||
return New(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIGetTasks(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var tasks []taskResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &tasks); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tasks) != 2 {
|
|
||||||
t.Fatalf("expected 2 tasks, got %d", len(tasks))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that tasks have correct statuses
|
|
||||||
statuses := map[string]string{}
|
|
||||||
for _, task := range tasks {
|
|
||||||
statuses[task.ID] = task.Status
|
|
||||||
}
|
|
||||||
if statuses["T01"] != "done" {
|
|
||||||
t.Errorf("expected T01 status done, got %s", statuses["T01"])
|
|
||||||
}
|
|
||||||
if statuses["T08"] != "backlog" {
|
|
||||||
t.Errorf("expected T08 status backlog, got %s", statuses["T08"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIGetTask(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/task/T01", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var detail taskDetailResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if detail.ID != "T01" {
|
|
||||||
t.Errorf("expected T01, got %s", detail.ID)
|
|
||||||
}
|
|
||||||
if detail.Content == "" {
|
|
||||||
t.Error("expected non-empty content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIGetTask_NotFound(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/task/T99", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file was moved
|
|
||||||
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err != nil {
|
|
||||||
t.Error("expected T08.md in ready/")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "backlog", "T08.md")); !os.IsNotExist(err) {
|
|
||||||
t.Error("expected T08.md removed from backlog/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask_NotFound(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T99/move?to=ready", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask_InvalidFolder(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=invalid", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardHTML(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, "KAOS Dashboard") {
|
|
||||||
t.Error("expected 'KAOS Dashboard' in HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "T01") {
|
|
||||||
t.Error("expected T01 in HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "T08") {
|
|
||||||
t.Error("expected T08 in HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "BACKLOG") {
|
|
||||||
t.Error("expected BACKLOG column in HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "DONE") {
|
|
||||||
t.Error("expected DONE column in HTML")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskDetailHTML(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, "T01") {
|
|
||||||
t.Error("expected T01 in detail HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "Prvi task") {
|
|
||||||
t.Error("expected task title in detail HTML")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskDetailHTML_NotFound(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/task/T99", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTMLMoveTask(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should return updated dashboard HTML
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, "KAOS Dashboard") {
|
|
||||||
t.Error("expected dashboard HTML after move")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardHTML_HasAllColumns(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
for _, col := range []string{"BACKLOG", "READY", "ACTIVE", "REVIEW", "DONE"} {
|
|
||||||
if !containsStr(body, col) {
|
|
||||||
t.Errorf("expected %s column in dashboard", col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardHTML_HasHTMXAttributes(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, "hx-get") {
|
|
||||||
t.Error("expected hx-get attributes in HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "hx-target") {
|
|
||||||
t.Error("expected hx-target attributes in HTML")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardHTML_TasksInCorrectColumns(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
// T01 should be in done column, T08 in backlog
|
|
||||||
if !containsStr(body, `id="col-done"`) {
|
|
||||||
t.Error("expected col-done in HTML")
|
|
||||||
}
|
|
||||||
if !containsStr(body, `id="col-backlog"`) {
|
|
||||||
t.Error("expected col-backlog in HTML")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReport_Exists(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// Create a report
|
|
||||||
reportsDir := filepath.Join(srv.Config.TasksDir, "reports")
|
|
||||||
os.MkdirAll(reportsDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 Report\nSve ok."), 0644)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/report/T01", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
if !containsStr(w.Body.String(), "T01 Report") {
|
|
||||||
t.Error("expected report content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReport_NotFound(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/report/T99", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskDetail_HasMoveButtons(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// T08 is in backlog, should have "Premesti u Ready" button
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/task/T08", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, "Ready") {
|
|
||||||
t.Error("expected 'Ready' move button for backlog task")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask_ForbiddenToActive(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// Put T08 in ready first
|
|
||||||
os.Rename(
|
|
||||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
|
||||||
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Try to move ready → active (agent-only)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=active", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("expected 403 for ready→active, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask_ForbiddenActiveToReview(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// Put T08 in active
|
|
||||||
os.Rename(
|
|
||||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
|
||||||
filepath.Join(srv.Config.TasksDir, "active", "T08.md"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Try to move active → review (agent-only)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=review", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("expected 403 for active→review, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask_AllowedBacklogToReady(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 for backlog→ready, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIMoveTask_AllowedReviewToDone(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// Put T08 in review
|
|
||||||
os.Rename(
|
|
||||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
|
||||||
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
|
|
||||||
)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 for review→done, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardHTML_HasSortableScript(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, "sortable.min.js") {
|
|
||||||
t.Error("expected sortable.min.js script tag")
|
|
||||||
}
|
|
||||||
if !containsStr(body, "initSortable") {
|
|
||||||
t.Error("expected initSortable function")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardHTML_HasDataFolderAttributes(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
if !containsStr(body, `data-folder="backlog"`) {
|
|
||||||
t.Error("expected data-folder attribute on column-tasks")
|
|
||||||
}
|
|
||||||
if !containsStr(body, `data-folder="ready"`) {
|
|
||||||
t.Error("expected data-folder=ready attribute")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsMoveAllowed(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
from, to string
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{"backlog", "ready", true},
|
|
||||||
{"ready", "backlog", true},
|
|
||||||
{"review", "done", true},
|
|
||||||
{"review", "ready", true},
|
|
||||||
{"done", "review", true},
|
|
||||||
{"ready", "active", false},
|
|
||||||
{"active", "review", false},
|
|
||||||
{"backlog", "done", false},
|
|
||||||
{"backlog", "active", false},
|
|
||||||
{"done", "backlog", false},
|
|
||||||
{"ready", "ready", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := isMoveAllowed(tt.from, tt.to)
|
|
||||||
if got != tt.allowed {
|
|
||||||
t.Errorf("isMoveAllowed(%s, %s) = %v, want %v", tt.from, tt.to, got, tt.allowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoCacheHeaders(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// Dynamic route should have no-cache headers
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
cc := w.Header().Get("Cache-Control")
|
|
||||||
if !containsStr(cc, "no-store") {
|
|
||||||
t.Errorf("expected Cache-Control no-store on dashboard, got %q", cc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// API route should also have no-cache headers
|
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w2, req2)
|
|
||||||
|
|
||||||
cc2 := w2.Header().Get("Cache-Control")
|
|
||||||
if !containsStr(cc2, "no-store") {
|
|
||||||
t.Errorf("expected Cache-Control no-store on API, got %q", cc2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardReflectsDiskChanges(t *testing.T) {
|
|
||||||
srv := setupTestServer(t)
|
|
||||||
|
|
||||||
// Initial state: T08 in backlog
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
var tasks1 []taskResponse
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &tasks1)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, task := range tasks1 {
|
|
||||||
if task.ID == "T08" && task.Status == "backlog" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("expected T08 in backlog initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move file on disk (simulating external change)
|
|
||||||
os.Rename(
|
|
||||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
|
||||||
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Second request should reflect the change without server restart
|
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
srv.Router.ServeHTTP(w2, req2)
|
|
||||||
|
|
||||||
var tasks2 []taskResponse
|
|
||||||
json.Unmarshal(w2.Body.Bytes(), &tasks2)
|
|
||||||
|
|
||||||
found = false
|
|
||||||
for _, task := range tasks2 {
|
|
||||||
if task.ID == "T08" && task.Status == "ready" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("expected T08 in ready after disk move — server did not read fresh state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsStr(s, substr string) bool {
|
|
||||||
return len(s) >= len(substr) && findStr(s, substr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findStr(s, substr string) bool {
|
|
||||||
for i := 0; i <= len(s)-len(substr); i++ {
|
|
||||||
if s[i:i+len(substr)] == substr {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
121
code/internal/server/sse_test.go
Normal file
121
code/internal/server/sse_test.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSE_EventsEndpoint(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/events", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Use a context with cancel to stop the SSE handler
|
||||||
|
ctx, cancel := req.Context(), func() {}
|
||||||
|
_ = ctx
|
||||||
|
_ = cancel
|
||||||
|
|
||||||
|
// Just check the handler starts without error and sets correct headers
|
||||||
|
go func() {
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" {
|
||||||
|
t.Errorf("expected Content-Type text/event-stream, got %s", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashTaskState(t *testing.T) {
|
||||||
|
tasks1 := []supervisor.Task{
|
||||||
|
{ID: "T01", Status: "done"},
|
||||||
|
{ID: "T02", Status: "backlog"},
|
||||||
|
}
|
||||||
|
tasks2 := []supervisor.Task{
|
||||||
|
{ID: "T01", Status: "done"},
|
||||||
|
{ID: "T02", Status: "ready"}, // changed
|
||||||
|
}
|
||||||
|
tasks3 := []supervisor.Task{
|
||||||
|
{ID: "T02", Status: "backlog"},
|
||||||
|
{ID: "T01", Status: "done"}, // same as tasks1 but different order
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 := hashTaskState(tasks1)
|
||||||
|
h2 := hashTaskState(tasks2)
|
||||||
|
h3 := hashTaskState(tasks3)
|
||||||
|
|
||||||
|
if h1 == h2 {
|
||||||
|
t.Error("hash should differ when task status changes")
|
||||||
|
}
|
||||||
|
if h1 != h3 {
|
||||||
|
t.Error("hash should be same regardless of task order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_BroadcastOnChange(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Subscribe a client
|
||||||
|
ch := srv.events.subscribe()
|
||||||
|
defer srv.events.unsubscribe(ch)
|
||||||
|
|
||||||
|
// Trigger a check — first call sets the baseline hash and broadcasts
|
||||||
|
srv.events.checkAndBroadcast(func() string { return "board-html" })
|
||||||
|
|
||||||
|
// Drain initial broadcast
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move a task to change state
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger another check — state changed, should broadcast
|
||||||
|
srv.events.checkAndBroadcast(func() string { return "updated-board" })
|
||||||
|
|
||||||
|
select {
|
||||||
|
case data := <-ch:
|
||||||
|
if data != "updated-board" {
|
||||||
|
t.Errorf("expected 'updated-board', got %s", data)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Error("expected broadcast after state change, got nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSE_NoBroadcastWithoutChange(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
ch := srv.events.subscribe()
|
||||||
|
defer srv.events.unsubscribe(ch)
|
||||||
|
|
||||||
|
// Two checks without changes — second should not broadcast
|
||||||
|
srv.events.checkAndBroadcast(func() string { return "board" })
|
||||||
|
|
||||||
|
// Drain the first broadcast (initial hash set)
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.events.checkAndBroadcast(func() string { return "board" })
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
t.Error("should not broadcast when state hasn't changed")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
// Good — no broadcast
|
||||||
|
}
|
||||||
|
}
|
||||||
313
code/internal/server/submit.go
Normal file
313
code/internal/server/submit.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
// Package server — submit.go handles task submission in two modes:
|
||||||
|
// client (simple form) and operator (chat via claude CLI).
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chatCounter generates unique chat IDs.
|
||||||
|
var chatCounter atomic.Int64
|
||||||
|
|
||||||
|
func nextChatID() string {
|
||||||
|
chatCounter.Add(1)
|
||||||
|
return fmt.Sprintf("chat-%d-%d", time.Now().Unix(), chatCounter.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEnv returns the current environment with CLAUDECODE removed,
|
||||||
|
// so child claude processes don't inherit the parent's session.
|
||||||
|
func cleanEnv() []string {
|
||||||
|
var env []string
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
if !strings.HasPrefix(e, "CLAUDECODE=") {
|
||||||
|
env = append(env, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatState manages an operator chat session backed by a claude CLI process.
|
||||||
|
type chatState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
id string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
output []string
|
||||||
|
done bool
|
||||||
|
listeners map[chan string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextTaskNumber finds the highest T{XX} number across all tasks and returns the next one.
|
||||||
|
func nextTaskNumber(tasksDir string) (string, error) {
|
||||||
|
tasks, err := supervisor.ScanTasks(tasksDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
maxNum := 0
|
||||||
|
re := regexp.MustCompile(`^T(\d+)$`)
|
||||||
|
for _, t := range tasks {
|
||||||
|
if matches := re.FindStringSubmatch(t.ID); matches != nil {
|
||||||
|
num, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if num > maxNum {
|
||||||
|
maxNum = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("T%02d", maxNum+1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubmitPage serves the submission page.
|
||||||
|
func (s *Server) handleSubmitPage(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, renderSubmitPage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSimpleSubmit creates a task in backlog/ from the client form.
|
||||||
|
func (s *Server) handleSimpleSubmit(c *gin.Context) {
|
||||||
|
title := strings.TrimSpace(c.PostForm("title"))
|
||||||
|
desc := strings.TrimSpace(c.PostForm("description"))
|
||||||
|
priority := strings.TrimSpace(c.PostForm("priority"))
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "naslov je obavezan"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if priority == "" {
|
||||||
|
priority = "Srednji"
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := nextTaskNumber(s.Config.TasksDir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Format("2006-01-02 15:04")
|
||||||
|
content := fmt.Sprintf(`# %s: %s
|
||||||
|
|
||||||
|
**Kreirao:** klijent (prijava)
|
||||||
|
**Datum:** %s
|
||||||
|
**Agent:** —
|
||||||
|
**Model:** —
|
||||||
|
**Zavisi od:** —
|
||||||
|
**Prioritet:** %s
|
||||||
|
**Izvor:** klijent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
## Originalna prijava
|
||||||
|
|
||||||
|
%s
|
||||||
|
`, taskID, title, now, priority, desc, desc)
|
||||||
|
|
||||||
|
path := filepath.Join(s.Config.TasksDir, "backlog", taskID+".md")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "task_id": taskID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChatSubmit spawns a claude CLI process with the operator's message.
|
||||||
|
func (s *Server) handleChatSubmit(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "neispravan zahtev"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.Message) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "poruka je obavezna"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build prompt with context
|
||||||
|
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
|
||||||
|
context := buildTaskContext(tasks)
|
||||||
|
|
||||||
|
projectRoot := filepath.Dir(s.Config.TasksDir)
|
||||||
|
claudeMD, _ := os.ReadFile(filepath.Join(projectRoot, "CLAUDE.md"))
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf("Kontekst (CLAUDE.md):\n%s\n\nTrenutni taskovi:\n%s\n\nOperater kaže:\n%s",
|
||||||
|
string(claudeMD), context, req.Message)
|
||||||
|
|
||||||
|
// Create chat session
|
||||||
|
chatID := nextChatID()
|
||||||
|
chat := &chatState{
|
||||||
|
id: chatID,
|
||||||
|
listeners: make(map[chan string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.chatMu.Lock()
|
||||||
|
s.chats[chatID] = chat
|
||||||
|
s.chatMu.Unlock()
|
||||||
|
|
||||||
|
// Spawn claude CLI process in background
|
||||||
|
go s.runChatCommand(chat, prompt)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"chat_id": chatID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runChatCommand executes claude CLI in a PTY and streams output to chat listeners.
|
||||||
|
func (s *Server) runChatCommand(chat *chatState, prompt string) {
|
||||||
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
||||||
|
cmd.Dir = s.projectRoot()
|
||||||
|
cmd.Env = cleanEnv()
|
||||||
|
|
||||||
|
ptmx, err := startPTY(cmd)
|
||||||
|
if err != nil {
|
||||||
|
sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]")
|
||||||
|
finishChat(chat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
|
chat.mu.Lock()
|
||||||
|
chat.cmd = cmd
|
||||||
|
chat.mu.Unlock()
|
||||||
|
|
||||||
|
// Read PTY output and send to chat
|
||||||
|
readPTY(ptmx, func(line string) {
|
||||||
|
sendChatLine(chat, line)
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd.Wait()
|
||||||
|
finishChat(chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendChatLine sends a line to all chat listeners and stores in output buffer.
|
||||||
|
func sendChatLine(chat *chatState, line string) {
|
||||||
|
chat.mu.Lock()
|
||||||
|
defer chat.mu.Unlock()
|
||||||
|
|
||||||
|
chat.output = append(chat.output, line)
|
||||||
|
|
||||||
|
for ch := range chat.listeners {
|
||||||
|
select {
|
||||||
|
case ch <- line:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishChat marks a chat as done and signals all listeners.
|
||||||
|
func finishChat(chat *chatState) {
|
||||||
|
chat.mu.Lock()
|
||||||
|
defer chat.mu.Unlock()
|
||||||
|
|
||||||
|
chat.done = true
|
||||||
|
chat.cmd = nil
|
||||||
|
|
||||||
|
for ch := range chat.listeners {
|
||||||
|
select {
|
||||||
|
case ch <- "[DONE]":
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChatStream serves an SSE stream for a chat session.
|
||||||
|
func (s *Server) handleChatStream(c *gin.Context) {
|
||||||
|
chatID := c.Param("id")
|
||||||
|
|
||||||
|
s.chatMu.RLock()
|
||||||
|
chat := s.chats[chatID]
|
||||||
|
s.chatMu.RUnlock()
|
||||||
|
|
||||||
|
if chat == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
ch := make(chan string, 100)
|
||||||
|
|
||||||
|
chat.mu.Lock()
|
||||||
|
// Replay buffered output
|
||||||
|
for _, line := range chat.output {
|
||||||
|
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
|
||||||
|
}
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
// If already done, send done event and return
|
||||||
|
if chat.done {
|
||||||
|
chat.mu.Unlock()
|
||||||
|
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.listeners[ch] = true
|
||||||
|
chat.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
chat.mu.Lock()
|
||||||
|
delete(chat.listeners, ch)
|
||||||
|
chat.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
notify := c.Request.Context().Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-notify:
|
||||||
|
return
|
||||||
|
case line := <-ch:
|
||||||
|
if line == "[DONE]" {
|
||||||
|
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTaskContext creates a text summary of current tasks for the system prompt.
|
||||||
|
func buildTaskContext(tasks []supervisor.Task) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, status := range []string{"backlog", "ready", "active", "review", "done"} {
|
||||||
|
sb.WriteString("### " + strings.ToUpper(status) + "\n")
|
||||||
|
found := false
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Status == status {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s: %s (%s, %s)\n", t.ID, t.Title, t.Agent, t.Model))
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
sb.WriteString("(prazno)\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
245
code/internal/server/submit_test.go
Normal file
245
code/internal/server/submit_test.go
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubmitPage(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Klijent") {
|
||||||
|
t.Error("expected 'Klijent' mode button")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Operater") {
|
||||||
|
t.Error("expected 'Operater' mode button")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "mode-client") {
|
||||||
|
t.Error("expected client mode section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitPage_ClientModeIsDefault(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// Operator mode should be hidden by default
|
||||||
|
if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) {
|
||||||
|
t.Error("expected operator mode to be hidden by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_CreatesTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Errorf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, ok := resp["task_id"].(string)
|
||||||
|
if !ok || taskID == "" {
|
||||||
|
t.Fatal("expected non-empty task_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was created in backlog
|
||||||
|
path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md")
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected task file in backlog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !containsStr(string(content), "Test prijava") {
|
||||||
|
t.Error("expected title in task file")
|
||||||
|
}
|
||||||
|
if !containsStr(string(content), "Visok") {
|
||||||
|
t.Error("expected priority in task file")
|
||||||
|
}
|
||||||
|
if !containsStr(string(content), "klijent (prijava)") {
|
||||||
|
t.Error("expected 'klijent (prijava)' as creator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_MissingTitle(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
form := strings.NewReader("description=Samo+opis")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for missing title, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_AutoNumbering(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Existing tasks: T01 (done), T08 (backlog)
|
||||||
|
// Next should be T09
|
||||||
|
|
||||||
|
form := strings.NewReader("title=Novi+task&priority=Srednji")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
taskID, _ := resp["task_id"].(string)
|
||||||
|
if taskID != "T09" {
|
||||||
|
t.Errorf("expected T09 (next after T08), got %s", taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_DefaultPriority(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
form := strings.NewReader("title=Bez+prioriteta")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
taskID, _ := resp["task_id"].(string)
|
||||||
|
path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md")
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
|
if !containsStr(string(content), "Srednji") {
|
||||||
|
t.Error("expected default priority 'Srednji'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSubmit_ReturnsChatID(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
body := `{"message":"test poruka"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp["chat_id"] == nil || resp["chat_id"] == "" {
|
||||||
|
t.Error("expected non-empty chat_id in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSubmit_EmptyMessage(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
body := `{"message":""}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for empty message, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatStream_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextTaskNumber(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tasksDir := filepath.Join(dir, "TASKS")
|
||||||
|
for _, f := range []string{"backlog", "ready", "active", "review", "done"} {
|
||||||
|
os.MkdirAll(filepath.Join(tasksDir, f), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
|
||||||
|
|
||||||
|
num, err := nextTaskNumber(tasksDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if num != "T09" {
|
||||||
|
t.Errorf("expected T09, got %s", num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskContext(t *testing.T) {
|
||||||
|
tasks := []supervisor.Task{
|
||||||
|
{ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"},
|
||||||
|
{ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := buildTaskContext(tasks)
|
||||||
|
if !containsStr(ctx, "T01: Init") {
|
||||||
|
t.Error("expected T01 in context")
|
||||||
|
}
|
||||||
|
if !containsStr(ctx, "T02: Server") {
|
||||||
|
t.Error("expected T02 in context")
|
||||||
|
}
|
||||||
|
if !containsStr(ctx, "DONE") {
|
||||||
|
t.Error("expected DONE section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitPage_HasPrijavaNav(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, `href="/submit"`) {
|
||||||
|
t.Error("expected Prijava nav link")
|
||||||
|
}
|
||||||
|
}
|
||||||
272
code/internal/server/task_detail_test.go
Normal file
272
code/internal/server/task_detail_test.go
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskDetailHTML(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "T01") {
|
||||||
|
t.Error("expected T01 in detail HTML")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Prvi task") {
|
||||||
|
t.Error("expected task title in detail HTML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetailHTML_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T99", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReport_Exists(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Create a report
|
||||||
|
reportsDir := filepath.Join(srv.Config.TasksDir, "reports")
|
||||||
|
os.MkdirAll(reportsDir, 0755)
|
||||||
|
os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 Report\nSve ok."), 0644)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/report/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !containsStr(w.Body.String(), "T01 Report") {
|
||||||
|
t.Error("expected report content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReport_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/report/T99", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTask_Ready(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Move T08 to ready first
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp["status"] != "started" {
|
||||||
|
t.Errorf("expected status started, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["task"] != "T08" {
|
||||||
|
t.Errorf("expected task T08, got %v", resp["task"])
|
||||||
|
}
|
||||||
|
// Verify task moved to active/
|
||||||
|
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "active", "T08.md")); err != nil {
|
||||||
|
t.Error("expected T08.md in active/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTask_BacklogWithDeps(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T08 depends on T07, T01 is in done
|
||||||
|
// T08 depends on T07 which is NOT in done → should fail
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for unmet deps, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTask_AlreadyDone(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T01 is in done
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T01/run", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for done task, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTask_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T99/run", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTask_MovesToActive(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Move T08 to ready
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify task moved to active/
|
||||||
|
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "active", "T08.md")); err != nil {
|
||||||
|
t.Error("expected T08.md in active/ after run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskAction_Blocked(t *testing.T) {
|
||||||
|
doneSet := map[string]bool{"T01": true}
|
||||||
|
task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}}
|
||||||
|
if got := resolveTaskAction(task, doneSet); got != "blocked" {
|
||||||
|
t.Errorf("expected blocked, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskAction_Review(t *testing.T) {
|
||||||
|
doneSet := map[string]bool{"T07": true}
|
||||||
|
task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}}
|
||||||
|
if got := resolveTaskAction(task, doneSet); got != "review" {
|
||||||
|
t.Errorf("expected review, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskAction_Run(t *testing.T) {
|
||||||
|
task := supervisor.Task{ID: "T08", Status: "ready"}
|
||||||
|
if got := resolveTaskAction(task, nil); got != "run" {
|
||||||
|
t.Errorf("expected run, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskAction_Running(t *testing.T) {
|
||||||
|
task := supervisor.Task{ID: "T08", Status: "active"}
|
||||||
|
if got := resolveTaskAction(task, nil); got != "running" {
|
||||||
|
t.Errorf("expected running, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskAction_Approve(t *testing.T) {
|
||||||
|
task := supervisor.Task{ID: "T08", Status: "review"}
|
||||||
|
if got := resolveTaskAction(task, nil); got != "approve" {
|
||||||
|
t.Errorf("expected approve, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskAction_Done(t *testing.T) {
|
||||||
|
task := supervisor.Task{ID: "T01", Status: "done"}
|
||||||
|
if got := resolveTaskAction(task, nil); got != "done" {
|
||||||
|
t.Errorf("expected done, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReport_RendersMarkdownInModal(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/report/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "detail-inner") {
|
||||||
|
t.Error("expected detail-inner wrapper for modal")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "docs-content") {
|
||||||
|
t.Error("expected docs-content class for markdown rendering")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "<h1>") {
|
||||||
|
t.Error("expected rendered markdown heading")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Izveštaj") {
|
||||||
|
t.Error("expected 'Izveštaj' in title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReport_NoReport_ShowsTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T08 has no report — should show task content
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/report/T08", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "detail-inner") {
|
||||||
|
t.Error("expected detail-inner wrapper")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "HTTP server") {
|
||||||
|
t.Error("expected task title in fallback content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReport_NotFoundTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/report/T99", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
code/internal/server/test_helpers_test.go
Normal file
82
code/internal/server/test_helpers_test.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dal/kaos/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testTask1 = `# T01: Prvi task
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Opis prvog taska.
|
||||||
|
|
||||||
|
---
|
||||||
|
`
|
||||||
|
|
||||||
|
const testTask2 = `# T08: HTTP server
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T07
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Implementacija HTTP servera.
|
||||||
|
|
||||||
|
---
|
||||||
|
`
|
||||||
|
|
||||||
|
func setupTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
tasksDir := filepath.Join(dir, "TASKS")
|
||||||
|
for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} {
|
||||||
|
os.MkdirAll(filepath.Join(tasksDir, folder), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
|
||||||
|
|
||||||
|
// Docs: create markdown files in project root
|
||||||
|
os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
TasksDir: tasksDir,
|
||||||
|
ProjectPath: dir,
|
||||||
|
Port: "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && findStr(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findStr(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
425
code/internal/server/timestamp_test.go
Normal file
425
code/internal/server/timestamp_test.go
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMoveTask_AddsTimestamp(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the moved file and check for timestamp
|
||||||
|
content, err := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read moved file: %v", err)
|
||||||
|
}
|
||||||
|
text := string(content)
|
||||||
|
if !containsStr(text, "## Vremena") {
|
||||||
|
t.Error("expected '## Vremena' section in task file")
|
||||||
|
}
|
||||||
|
if !containsStr(text, "Odobren (→ready)") {
|
||||||
|
t.Error("expected 'Odobren (→ready)' timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveTask_AppendsMultipleTimestamps(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Move backlog → ready
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Move ready → backlog (return)
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=backlog", nil)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
// Need to re-fetch since task is now in ready/
|
||||||
|
srv.Router.ServeHTTP(w2, req2)
|
||||||
|
|
||||||
|
// Move backlog → ready again
|
||||||
|
req3 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
|
||||||
|
w3 := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w3, req3)
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
|
||||||
|
text := string(content)
|
||||||
|
|
||||||
|
// Should have two "Odobren (→ready)" entries
|
||||||
|
count := strings.Count(text, "Odobren (→ready)")
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected 2 'Odobren (→ready)' timestamps, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTimestamp_CreatesTable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.md")
|
||||||
|
os.WriteFile(path, []byte("# T01: Test\n\nSome content.\n"), 0644)
|
||||||
|
|
||||||
|
err := appendTimestamp(path, "Odobren (→ready)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
text := string(content)
|
||||||
|
if !containsStr(text, "## Vremena") {
|
||||||
|
t.Error("expected Vremena section")
|
||||||
|
}
|
||||||
|
if !containsStr(text, "| Događaj | Vreme |") {
|
||||||
|
t.Error("expected table header")
|
||||||
|
}
|
||||||
|
if !containsStr(text, "Odobren (→ready)") {
|
||||||
|
t.Error("expected timestamp row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTimestamp_AppendsToExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.md")
|
||||||
|
os.WriteFile(path, []byte("# T01: Test\n\n## Vremena\n\n| Događaj | Vreme |\n|---------|-------|\n| Kreiran | 2026-02-20 14:00 |\n"), 0644)
|
||||||
|
|
||||||
|
err := appendTimestamp(path, "Odobren (→ready)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
text := string(content)
|
||||||
|
if !containsStr(text, "Kreiran") {
|
||||||
|
t.Error("expected existing row preserved")
|
||||||
|
}
|
||||||
|
if !containsStr(text, "Odobren (→ready)") {
|
||||||
|
t.Error("expected new timestamp row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTask_AddsTimestamp(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Move T08 to ready
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task should now be in active/
|
||||||
|
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "active", "T08.md"))
|
||||||
|
text := string(content)
|
||||||
|
if !containsStr(text, "Pokrenut") {
|
||||||
|
t.Error("expected 'Pokrenut' timestamp after run")
|
||||||
|
}
|
||||||
|
// Verify it's no longer in ready/
|
||||||
|
if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err == nil {
|
||||||
|
t.Error("task should no longer be in ready/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTimestamp_Format(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.md")
|
||||||
|
os.WriteFile(path, []byte("# T01: Test\n"), 0644)
|
||||||
|
|
||||||
|
appendTimestamp(path, "Pokrenut (→active)")
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
text := string(content)
|
||||||
|
|
||||||
|
// Verify timestamp format YYYY-MM-DD HH:MM
|
||||||
|
re := regexp.MustCompile(`\| Pokrenut \(→active\) \| \d{4}-\d{2}-\d{2} \d{2}:\d{2} \|`)
|
||||||
|
if !re.MatchString(text) {
|
||||||
|
t.Errorf("expected timestamp format YYYY-MM-DD HH:MM, got:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTimestamp_FileNotFound(t *testing.T) {
|
||||||
|
err := appendTimestamp("/nonexistent/path/task.md", "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveEventLabel_AllFolders(t *testing.T) {
|
||||||
|
expected := map[string]string{
|
||||||
|
"ready": "Odobren (→ready)",
|
||||||
|
"active": "Pokrenut (→active)",
|
||||||
|
"review": "Završen (→review)",
|
||||||
|
"done": "Odobren (→done)",
|
||||||
|
}
|
||||||
|
|
||||||
|
for folder, label := range expected {
|
||||||
|
got, ok := moveEventLabel[folder]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing label for folder %s", folder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != label {
|
||||||
|
t.Errorf("folder %s: expected %q, got %q", folder, label, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoneTimestamp_ReviewToDone(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Put T08 in review
|
||||||
|
os.Rename(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||||
|
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "done", "T08.md"))
|
||||||
|
text := string(content)
|
||||||
|
|
||||||
|
if !containsStr(text, "Odobren (→done)") {
|
||||||
|
t.Error("expected 'Odobren (→done)' timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_DoneShowsReportButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// T01 is in done and has a report
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "/report/T01") {
|
||||||
|
t.Error("expected report link for done task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_DoneWithoutReportShowsButton(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Create a done task without a report
|
||||||
|
os.WriteFile(
|
||||||
|
filepath.Join(srv.Config.TasksDir, "done", "T02.md"),
|
||||||
|
[]byte("# T02: Bez reporta\n\n**Agent:** coder\n**Model:** Sonnet\n**Zavisi od:** —\n\n---\n\n## Opis\n\nTest task bez reporta.\n"),
|
||||||
|
0644,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T02", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "/report/T02") {
|
||||||
|
t.Error("expected report button for done task even without report file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimestampVisibleInTaskDetail(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Add timestamps to T01 (done task)
|
||||||
|
taskPath := filepath.Join(srv.Config.TasksDir, "done", "T01.md")
|
||||||
|
appendTimestamp(taskPath, "Kreiran")
|
||||||
|
appendTimestamp(taskPath, "Pokrenut (→active)")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Vremena") {
|
||||||
|
t.Error("expected Vremena section visible in task detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnsi(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"hello", "hello"},
|
||||||
|
{"\x1b[32mgreen\x1b[0m", "green"},
|
||||||
|
{"\x1b[1;31mbold red\x1b[0m", "bold red"},
|
||||||
|
{"\x1b[?25l\x1b[?25h", ""},
|
||||||
|
{"no \x1b[4munderline\x1b[24m here", "no underline here"},
|
||||||
|
{"\x1b]0;title\x07text", "text"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := stripAnsi(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPTY_SplitsLines(t *testing.T) {
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readPTY(r, func(line string) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteString("line1\nline2\nline3\n")
|
||||||
|
w.Close()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" {
|
||||||
|
t.Errorf("unexpected lines: %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPTY_StripsAnsi(t *testing.T) {
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readPTY(r, func(line string) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteString("\x1b[32mcolored\x1b[0m\n")
|
||||||
|
w.Close()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("expected 1 line, got %d", len(lines))
|
||||||
|
}
|
||||||
|
if lines[0] != "colored" {
|
||||||
|
t.Errorf("expected 'colored', got %q", lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPTY_HandlesPartialChunks(t *testing.T) {
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readPTY(r, func(line string) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Write partial, then complete
|
||||||
|
w.WriteString("partial")
|
||||||
|
w.Close()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("expected 1 line for partial, got %d: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
if lines[0] != "partial" {
|
||||||
|
t.Errorf("expected 'partial', got %q", lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPTY_HandlesCarriageReturn(t *testing.T) {
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readPTY(r, func(line string) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteString("line1\r\nline2\r\n")
|
||||||
|
w.Close()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
if lines[0] != "line1" || lines[1] != "line2" {
|
||||||
|
t.Errorf("unexpected lines: %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_WriteAndRead(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(16)
|
||||||
|
rb.Write([]byte("hello"))
|
||||||
|
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "hello" {
|
||||||
|
t.Errorf("expected 'hello', got '%s'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Overflow(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(8)
|
||||||
|
rb.Write([]byte("abcdefgh")) // exactly fills
|
||||||
|
rb.Write([]byte("ij")) // wraps around
|
||||||
|
|
||||||
|
got := rb.Bytes()
|
||||||
|
// Should contain the last 8 bytes: "cdefghij"
|
||||||
|
if string(got) != "cdefghij" {
|
||||||
|
t.Errorf("expected 'cdefghij', got '%s'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRingBuffer_Reset(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(16)
|
||||||
|
rb.Write([]byte("test"))
|
||||||
|
rb.Reset()
|
||||||
|
|
||||||
|
got := rb.Bytes()
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("expected empty after reset, got %d bytes", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
80
code/internal/server/ui_test.go
Normal file
80
code/internal/server/ui_test.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDashboard_EscapeClosesOverlay(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Escape") {
|
||||||
|
t.Error("expected Escape key handler for overlay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_BackdropClickClosesOverlay(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "e.target === this") {
|
||||||
|
t.Error("expected backdrop click handler for overlay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_HasInnerWrapper(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "detail-inner") {
|
||||||
|
t.Error("expected detail-inner wrapper in task detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetail_RendersMarkdownAsHTML(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/task/T01", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// Markdown headers should be rendered as HTML <h1> tags
|
||||||
|
if !containsStr(body, "<h1>") {
|
||||||
|
t.Error("expected rendered <h1> from markdown heading")
|
||||||
|
}
|
||||||
|
// Should have docs-content class for proper styling
|
||||||
|
if !containsStr(body, "docs-content") {
|
||||||
|
t.Error("expected docs-content class on detail content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsPage_HasFullHeightLayout(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/docs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "docs-container") {
|
||||||
|
t.Error("expected docs-container class")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "docs-layout") {
|
||||||
|
t.Error("expected docs-layout class for grid layout")
|
||||||
|
}
|
||||||
|
}
|
||||||
128
code/internal/server/ws.go
Normal file
128
code/internal/server/ws.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wsUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsResizeMsg is sent from the browser when the terminal size changes.
|
||||||
|
type wsResizeMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConsoleWS handles WebSocket connections for task console terminals.
|
||||||
|
// Each connection attaches to an existing PTY session by task key.
|
||||||
|
func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||||
|
key := c.Param("key") // e.g., "T08" or "T08-review"
|
||||||
|
|
||||||
|
sess := s.console.getSessionByKey(key)
|
||||||
|
if sess == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WS[%s]: upgrade error: %v", key, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
log.Printf("WS[%s]: connected", key)
|
||||||
|
|
||||||
|
ptySess := sess.PTY
|
||||||
|
|
||||||
|
// Send replay buffer so the user sees existing output
|
||||||
|
replay := ptySess.GetBuffer()
|
||||||
|
if len(replay) > 0 {
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, replay); err != nil {
|
||||||
|
log.Printf("WS[%s]: replay write error: %v", key, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("WS[%s]: replayed %d bytes", key, len(replay))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to new PTY output
|
||||||
|
subID := key + "-ws"
|
||||||
|
outputCh := ptySess.Subscribe(subID)
|
||||||
|
defer ptySess.Unsubscribe(subID)
|
||||||
|
|
||||||
|
// Serialized write channel
|
||||||
|
writeCh := make(chan []byte, 256)
|
||||||
|
writeDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(writeDone)
|
||||||
|
for data := range writeCh {
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// PTY output → WebSocket
|
||||||
|
go func() {
|
||||||
|
for data := range outputCh {
|
||||||
|
select {
|
||||||
|
case writeCh <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Signal to stop goroutines when read pump exits
|
||||||
|
stopCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Watch for process exit
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ptySess.Done():
|
||||||
|
log.Printf("WS[%s]: process exited", key)
|
||||||
|
conn.WriteMessage(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
|
||||||
|
case <-stopCh:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// WebSocket → PTY (read pump)
|
||||||
|
for {
|
||||||
|
_, msg, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
log.Printf("WS[%s]: read error: %v", key, err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for resize message
|
||||||
|
var resize wsResizeMsg
|
||||||
|
if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 {
|
||||||
|
if err := ptySess.Resize(resize.Rows, resize.Cols); err != nil {
|
||||||
|
log.Printf("WS[%s]: resize error: %v", key, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular keyboard input → PTY
|
||||||
|
if _, err := ptySess.WriteInput(msg); err != nil {
|
||||||
|
log.Printf("WS[%s]: write error: %v", key, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(stopCh)
|
||||||
|
close(writeCh)
|
||||||
|
<-writeDone
|
||||||
|
log.Printf("WS[%s]: disconnected", key)
|
||||||
|
}
|
||||||
@ -1,23 +1,115 @@
|
|||||||
|
/* === CSS varijable — teme === */
|
||||||
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-page: #1a1a2e;
|
||||||
|
--bg-panel: #16213e;
|
||||||
|
--bg-deep: #111;
|
||||||
|
--bg-hover: #0f3460;
|
||||||
|
--border: #0f3460;
|
||||||
|
--border-light: #333;
|
||||||
|
--border-disabled: #444;
|
||||||
|
--accent: #e94560;
|
||||||
|
--success: #4ecca3;
|
||||||
|
--info: #6ec6ff;
|
||||||
|
--warning: #ffd93d;
|
||||||
|
--text: #eee;
|
||||||
|
--text-light: #ddd;
|
||||||
|
--text-secondary: #aaa;
|
||||||
|
--text-muted: #888;
|
||||||
|
--text-dim: #666;
|
||||||
|
--text-disabled: #555;
|
||||||
|
--text-on-color: #1a1a2e;
|
||||||
|
--overlay: rgba(0,0,0,0.6);
|
||||||
|
--shadow: rgba(0,0,0,0.4);
|
||||||
|
--drag-over: rgba(15, 52, 96, 0.3);
|
||||||
|
--accent-shadow: rgba(233, 69, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-page: #f0f2f5;
|
||||||
|
--bg-panel: #ffffff;
|
||||||
|
--bg-deep: #f5f6fa;
|
||||||
|
--bg-hover: #e0e5ed;
|
||||||
|
--border: #c9d1db;
|
||||||
|
--border-light: #d8dee6;
|
||||||
|
--border-disabled: #ccc;
|
||||||
|
--accent: #d63851;
|
||||||
|
--success: #1a8a6a;
|
||||||
|
--info: #2b7dbd;
|
||||||
|
--warning: #b8860b;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-light: #334155;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--text-disabled: #a0aec0;
|
||||||
|
--text-on-color: #ffffff;
|
||||||
|
--overlay: rgba(0,0,0,0.35);
|
||||||
|
--shadow: rgba(0,0,0,0.12);
|
||||||
|
--drag-over: rgba(59, 130, 246, 0.1);
|
||||||
|
--accent-shadow: rgba(214, 56, 81, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Reset === */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
.header {
|
.header {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 2px solid #0f3460;
|
border-bottom: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 { font-size: 1.4em; }
|
.header h1 { font-size: 1.4em; }
|
||||||
.header .version { color: #888; font-size: 0.9em; }
|
.header .version { color: var(--text-muted); font-size: 0.9em; }
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Theme toggle === */
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Board (Kanban) === */
|
||||||
.board {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@ -27,7 +119,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
background: #16213e;
|
background: var(--bg-panel);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@ -37,21 +129,22 @@ body {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-bottom: 2px solid #0f3460;
|
border-bottom: 2px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-count {
|
.column-count {
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Task cards === */
|
||||||
.task-card {
|
.task-card {
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@ -60,13 +153,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-card:hover {
|
.task-card:hover {
|
||||||
border-color: #e94560;
|
border-color: var(--accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-id {
|
.task-id {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #e94560;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-title {
|
.task-title {
|
||||||
@ -77,45 +170,59 @@ body {
|
|||||||
.task-meta {
|
.task-meta {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-deps {
|
.task-deps {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: #666;
|
color: var(--text-dim);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task detail panel */
|
/* === Task detail modal === */
|
||||||
#task-detail {
|
#task-detail {
|
||||||
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -420px;
|
left: 0;
|
||||||
width: 420px;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #16213e;
|
z-index: 50;
|
||||||
border-left: 2px solid #0f3460;
|
background: var(--overlay);
|
||||||
padding: 20px;
|
justify-content: center;
|
||||||
overflow-y: auto;
|
align-items: center;
|
||||||
z-index: 10;
|
|
||||||
transition: right 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#task-detail.active {
|
#task-detail.active {
|
||||||
right: 0;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-inner {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 700px;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-close {
|
.detail-close {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
float: right;
|
position: absolute;
|
||||||
font-size: 1.2em;
|
top: 12px;
|
||||||
color: #888;
|
right: 16px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-close:hover { color: #e94560; }
|
.detail-close:hover { color: var(--accent); }
|
||||||
|
|
||||||
.detail-meta { margin-top: 12px; font-size: 0.9em; color: #aaa; }
|
.detail-meta { margin-top: 12px; font-size: 0.9em; color: var(--text-secondary); }
|
||||||
.detail-meta p { margin-bottom: 4px; }
|
.detail-meta p { margin-bottom: 4px; }
|
||||||
|
|
||||||
.detail-actions {
|
.detail-actions {
|
||||||
@ -125,52 +232,94 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Buttons === */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border-light);
|
||||||
background: #1a1a2e;
|
background: var(--bg-page);
|
||||||
color: #eee;
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover { background: #0f3460; }
|
.btn:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
.btn-move { border-color: #e94560; }
|
.btn-move { border-color: var(--accent); }
|
||||||
.btn-success { border-color: #4ecca3; color: #4ecca3; }
|
|
||||||
.btn-success:hover { background: #4ecca3; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.detail-content {
|
.task-action {
|
||||||
white-space: pre-wrap;
|
margin-top: 6px;
|
||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
text-align: right;
|
||||||
font-size: 0.8em;
|
|
||||||
margin-top: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 12px;
|
|
||||||
background: #111;
|
|
||||||
border-radius: 6px;
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sortable column-tasks container */
|
.task-action .btn {
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-run { border-color: var(--success); color: var(--success); }
|
||||||
|
.btn-run:hover { background: var(--success); color: var(--text-on-color); }
|
||||||
|
|
||||||
|
.btn-review { border-color: var(--info); color: var(--info); }
|
||||||
|
.btn-review:hover { background: var(--info); color: var(--text-on-color); }
|
||||||
|
|
||||||
|
.btn-approve { border-color: var(--warning); color: var(--warning); }
|
||||||
|
.btn-approve:hover { background: var(--warning); color: var(--text-on-color); }
|
||||||
|
|
||||||
|
.btn-report { border-color: var(--text-muted); color: var(--text-muted); }
|
||||||
|
.btn-report:hover { background: var(--text-muted); color: var(--text-on-color); }
|
||||||
|
|
||||||
|
.btn-blocked {
|
||||||
|
border-color: var(--border-disabled);
|
||||||
|
color: var(--text-disabled);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-running {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: default;
|
||||||
|
animation: pulse 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success { border-color: var(--success); color: var(--success); }
|
||||||
|
.btn-success:hover { background: var(--success); color: var(--text-on-color); }
|
||||||
|
|
||||||
|
.btn-active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Sortable / Drag & Drop === */
|
||||||
.column-tasks {
|
.column-tasks {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag & Drop styles */
|
|
||||||
.task-ghost {
|
.task-ghost {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
border: 2px dashed #e94560;
|
border: 2px dashed var(--accent);
|
||||||
background: #0f3460;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-chosen {
|
.task-chosen {
|
||||||
box-shadow: 0 4px 16px rgba(233, 69, 96, 0.3);
|
box-shadow: 0 4px 16px var(--accent-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-drag {
|
.task-drag {
|
||||||
@ -178,27 +327,26 @@ body {
|
|||||||
transform: rotate(2deg);
|
transform: rotate(2deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drop zone highlight */
|
|
||||||
.column-tasks.sortable-drag-over {
|
.column-tasks.sortable-drag-over {
|
||||||
background: rgba(15, 52, 96, 0.3);
|
background: var(--drag-over);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flash animations */
|
/* === Flash animations === */
|
||||||
@keyframes flash-success {
|
@keyframes flash-success {
|
||||||
0% { background: #4ecca3; }
|
0% { background: var(--success); }
|
||||||
100% { background: #1a1a2e; }
|
100% { background: var(--bg-page); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flash-error {
|
@keyframes flash-error {
|
||||||
0% { background: #e94560; }
|
0% { background: var(--accent); }
|
||||||
100% { background: #1a1a2e; }
|
100% { background: var(--bg-page); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-success { animation: flash-success 0.5s ease; }
|
.flash-success { animation: flash-success 0.5s ease; }
|
||||||
.flash-error { animation: flash-error 0.5s ease; }
|
.flash-error { animation: flash-error 0.5s ease; }
|
||||||
|
|
||||||
/* Toast notifications */
|
/* === Toast === */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
@ -217,23 +365,545 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-success {
|
.toast-success {
|
||||||
background: #4ecca3;
|
background: var(--success);
|
||||||
color: #1a1a2e;
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-error {
|
.toast-error {
|
||||||
background: #e94560;
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* === Search === */
|
||||||
|
.search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
width: 220px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px var(--shadow);
|
||||||
|
z-index: 50;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-dropdown:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon { font-size: 0.9em; }
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status {
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-status-done { color: var(--success); }
|
||||||
|
.search-status-active { color: var(--accent); }
|
||||||
|
.search-status-review { color: var(--warning); }
|
||||||
|
.search-status-ready { color: var(--info); }
|
||||||
|
.search-status-backlog { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.search-snippet {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Navigation === */
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Docs === */
|
||||||
|
.docs-container {
|
||||||
|
padding: 16px 24px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 25% 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-sidebar h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-icon { font-size: 1em; }
|
||||||
|
|
||||||
|
.docs-breadcrumbs {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-breadcrumbs a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-breadcrumbs a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-sidebar {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-main {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content h1, .docs-content h2, .docs-content h3 {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-top: 1.2em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid var(--border-light); padding-bottom: 8px; }
|
||||||
|
.docs-content h2 { font-size: 1.2em; }
|
||||||
|
.docs-content h3 { font-size: 1.05em; }
|
||||||
|
|
||||||
|
.docs-content a {
|
||||||
|
color: var(--success);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.docs-content code {
|
||||||
|
background: var(--bg-page);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content pre {
|
||||||
|
background: var(--bg-page);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content th, .docs-content td {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content th {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content ul, .docs-content ol {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content li { margin: 4px 0; }
|
||||||
|
|
||||||
|
.docs-content blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Console === */
|
||||||
|
.console-container {
|
||||||
|
padding: 16px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-panels {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-idle { color: var(--text-muted); }
|
||||||
|
.session-running { color: var(--success); }
|
||||||
|
|
||||||
|
.btn-kill {
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-terminal {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-terminal .xterm {
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-terminal .xterm-screen {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-terminal .xterm-viewport {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-toolbar {
|
||||||
|
padding: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Submit / Prijava === */
|
||||||
|
.submit-container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-mode h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-input {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-msg {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-success {
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-error {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Chat (operator mode) === */
|
||||||
|
#mode-operator {
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-role {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user {
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bot {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Placeholder text === */
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.board { grid-template-columns: repeat(3, 1fr); }
|
.board { grid-template-columns: repeat(3, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.board { grid-template-columns: repeat(2, 1fr); }
|
.board { grid-template-columns: repeat(2, 1fr); }
|
||||||
#task-detail { width: 100%; right: -100%; }
|
.docs-layout { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
|
|||||||
264
code/web/templates/console.html
Normal file
264
code/web/templates/console.html
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
{{define "console"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KAOS — Konzola</title>
|
||||||
|
<script>(function(){var m=localStorage.getItem('kaos-theme')||'dark',t=m;if(m==='auto'){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}document.documentElement.setAttribute('data-theme',t)})()</script>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>KAOS Dashboard</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
|
||||||
|
<button class="theme-btn" data-theme-mode="dark" onclick="setTheme('dark')" title="Tamna tema">🌙</button>
|
||||||
|
<button class="theme-btn" data-theme-mode="auto" onclick="setTheme('auto')" title="Sistemska tema">🔄</button>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn">Kanban</a>
|
||||||
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
|
<a href="/console" class="btn btn-active">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="console-container">
|
||||||
|
<div class="console-toolbar">
|
||||||
|
<span id="session-info">Sesije: 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="console-panels" id="panels">
|
||||||
|
<div class="console-empty" id="empty-state">
|
||||||
|
<p>Nema aktivnih sesija. Kliknite "Pusti" na tasku da pokrenete rad.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Terminal themes ──────────────────────────────────
|
||||||
|
var TERM_THEMES = {
|
||||||
|
dark: {
|
||||||
|
background: '#0d1117', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#0d1117',
|
||||||
|
selectionBackground: 'rgba(233,69,96,0.3)',
|
||||||
|
black: '#0d1117', red: '#f44336', green: '#4caf50', yellow: '#ff9800',
|
||||||
|
blue: '#2196f3', magenta: '#e94560', cyan: '#00bcd4', white: '#e0e0e0',
|
||||||
|
brightBlack: '#6c6c80', brightRed: '#ff6b81', brightGreen: '#66bb6a', brightYellow: '#ffb74d',
|
||||||
|
brightBlue: '#64b5f6', brightMagenta: '#ff6b81', brightCyan: '#4dd0e1', brightWhite: '#ffffff'
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
background: '#f5f6fa', foreground: '#1e293b', cursor: '#d63851', cursorAccent: '#f5f6fa',
|
||||||
|
selectionBackground: 'rgba(214,56,81,0.15)',
|
||||||
|
black: '#1e293b', red: '#dc322f', green: '#859900', yellow: '#b58900',
|
||||||
|
blue: '#268bd2', magenta: '#d63851', cyan: '#2aa198', white: '#eee8d5',
|
||||||
|
brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83',
|
||||||
|
brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#002b36'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTermTheme() {
|
||||||
|
var t = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
return TERM_THEMES[t] || TERM_THEMES.dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session state ────────────────────────────────────
|
||||||
|
var terminals = {};
|
||||||
|
|
||||||
|
function sessionKey(sess) {
|
||||||
|
return sess.type === 'review' ? sess.task_id + '-review' : sess.task_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminal(sess) {
|
||||||
|
var key = sessionKey(sess);
|
||||||
|
if (terminals[key]) return;
|
||||||
|
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.className = 'console-panel';
|
||||||
|
panel.id = 'panel-' + key;
|
||||||
|
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'console-panel-header';
|
||||||
|
var label = sess.task_id + (sess.type === 'review' ? ' [pregled]' : ' [rad]');
|
||||||
|
header.innerHTML = '<span>' + label + '</span>' +
|
||||||
|
'<span class="session-status session-running" id="status-' + key + '">' + sess.status + '</span>' +
|
||||||
|
'<button class="btn btn-sm" onclick="killSession(\'' + sess.task_id + '\', \'' + sess.type + '\')">Ugasi</button>';
|
||||||
|
|
||||||
|
var termDiv = document.createElement('div');
|
||||||
|
termDiv.className = 'console-terminal';
|
||||||
|
termDiv.id = 'terminal-' + key;
|
||||||
|
|
||||||
|
panel.appendChild(header);
|
||||||
|
panel.appendChild(termDiv);
|
||||||
|
document.getElementById('panels').appendChild(panel);
|
||||||
|
|
||||||
|
var theme = getTermTheme();
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
cursorInactiveStyle: 'outline',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||||
|
theme: theme,
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 10000,
|
||||||
|
convertEol: false,
|
||||||
|
drawBoldTextInBrightColors: true
|
||||||
|
});
|
||||||
|
|
||||||
|
var fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||||
|
term.open(termDiv);
|
||||||
|
|
||||||
|
terminals[key] = { term: term, fitAddon: fitAddon, ws: null };
|
||||||
|
|
||||||
|
termDiv.addEventListener('click', function() { term.focus(); });
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
fitAddon.fit();
|
||||||
|
connectWS(key, term);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket connection ─────────────────────────────
|
||||||
|
function connectWS(key, term) {
|
||||||
|
var sess = terminals[key];
|
||||||
|
if (!sess) return;
|
||||||
|
|
||||||
|
if (sess.ws) {
|
||||||
|
sess.ws.close();
|
||||||
|
sess.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
var url = proto + '//' + location.host + '/console/ws/' + key;
|
||||||
|
var ws = new WebSocket(url);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
var el = document.getElementById('status-' + key);
|
||||||
|
if (el) { el.textContent = 'connected'; el.className = 'session-status session-running'; }
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
term.write(new Uint8Array(event.data));
|
||||||
|
} else {
|
||||||
|
term.write(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function() {
|
||||||
|
sess.ws = null;
|
||||||
|
var el = document.getElementById('status-' + key);
|
||||||
|
if (el) { el.textContent = 'disconnected'; el.className = 'session-status'; }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function() {
|
||||||
|
sess.ws = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard input → WebSocket
|
||||||
|
term.onData(function(data) {
|
||||||
|
if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
|
||||||
|
sess.ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize → WebSocket
|
||||||
|
term.onResize(function(size) {
|
||||||
|
if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
|
||||||
|
sess.ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sess.ws = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session management ───────────────────────────────
|
||||||
|
function killSession(taskID, type) {
|
||||||
|
fetch('/console/kill/' + taskID + '?type=' + type, { method: 'POST' })
|
||||||
|
.then(function(resp) { return resp.json(); })
|
||||||
|
.then(function() { refreshSessions(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSessions() {
|
||||||
|
fetch('/console/sessions')
|
||||||
|
.then(function(resp) { return resp.json(); })
|
||||||
|
.then(function(sessions) {
|
||||||
|
var info = document.getElementById('session-info');
|
||||||
|
info.textContent = 'Sesije: ' + sessions.length;
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentKeys = {};
|
||||||
|
sessions.forEach(function(sess) {
|
||||||
|
var key = sessionKey(sess);
|
||||||
|
currentKeys[key] = true;
|
||||||
|
createTerminal(sess);
|
||||||
|
|
||||||
|
var el = document.getElementById('status-' + key);
|
||||||
|
if (el && sess.status === 'exited') {
|
||||||
|
el.textContent = 'finished';
|
||||||
|
el.className = 'session-status session-done';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove panels for sessions that no longer exist
|
||||||
|
Object.keys(terminals).forEach(function(key) {
|
||||||
|
if (!currentKeys[key]) {
|
||||||
|
var panel = document.getElementById('panel-' + key);
|
||||||
|
if (panel) panel.remove();
|
||||||
|
if (terminals[key].ws) terminals[key].ws.close();
|
||||||
|
delete terminals[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Theme sync ───────────────────────────────────────
|
||||||
|
var origSetTheme = window.setTheme;
|
||||||
|
window.setTheme = function(mode) {
|
||||||
|
if (origSetTheme) origSetTheme(mode);
|
||||||
|
setTimeout(function() {
|
||||||
|
var theme = getTermTheme();
|
||||||
|
Object.keys(terminals).forEach(function(key) {
|
||||||
|
if (terminals[key].term) {
|
||||||
|
terminals[key].term.options.theme = theme;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Window resize ────────────────────────────────────
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
Object.keys(terminals).forEach(function(key) {
|
||||||
|
if (terminals[key].fitAddon) {
|
||||||
|
terminals[key].fitAddon.fit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initialize ───────────────────────────────────────
|
||||||
|
refreshSessions();
|
||||||
|
setInterval(refreshSessions, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
43
code/web/templates/docs-list.html
Normal file
43
code/web/templates/docs-list.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{{define "docs-list"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KAOS — Dokumenti</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔧 KAOS Dashboard</h1>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn">Kanban</a>
|
||||||
|
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
||||||
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="docs-container">
|
||||||
|
<div class="docs-layout">
|
||||||
|
<div class="docs-sidebar">
|
||||||
|
<h2>Dokumentacija</h2>
|
||||||
|
<div class="docs-list">
|
||||||
|
{{range .Files}}
|
||||||
|
<a href="/docs/{{.Path}}" class="doc-item" hx-get="/docs/{{.Path}}" hx-target="#docs-content" hx-push-url="true">
|
||||||
|
<span class="doc-icon">📄</span>
|
||||||
|
<span class="doc-name">{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-main">
|
||||||
|
<div class="docs-content" id="docs-content">
|
||||||
|
<p style="color:#888">Klikni na fajl da vidiš sadržaj.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
50
code/web/templates/docs-view.html
Normal file
50
code/web/templates/docs-view.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{{define "docs-view"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KAOS — {{.Path}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔧 KAOS Dashboard</h1>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn">Kanban</a>
|
||||||
|
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
||||||
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="docs-container">
|
||||||
|
<div class="docs-layout">
|
||||||
|
<div class="docs-sidebar">
|
||||||
|
<h2>Dokumentacija</h2>
|
||||||
|
<div class="docs-list">
|
||||||
|
{{range .Files}}
|
||||||
|
<a href="/docs/{{.Path}}" class="doc-item" hx-get="/docs/{{.Path}}" hx-target="#docs-content" hx-push-url="true">
|
||||||
|
<span class="doc-icon">📄</span>
|
||||||
|
<span class="doc-name">{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-main">
|
||||||
|
<div class="docs-content" id="docs-content">
|
||||||
|
<div class="docs-breadcrumbs">
|
||||||
|
<a href="/docs">Dokumenti</a>
|
||||||
|
{{range .Breadcrumbs}}
|
||||||
|
<span class="breadcrumb-sep">›</span>
|
||||||
|
<a href="/docs/{{.Path}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{.HTML}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@ -4,14 +4,39 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>KAOS Dashboard</title>
|
<title>KAOS Dashboard</title>
|
||||||
|
<script>(function(){var m=localStorage.getItem('kaos-theme')||'dark',t=m;if(m==='auto'){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}document.documentElement.setAttribute('data-theme',t)})()</script>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/sortable.min.js"></script>
|
<script src="/static/sortable.min.js"></script>
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🔧 KAOS Dashboard</h1>
|
<h1>🔧 KAOS Dashboard</h1>
|
||||||
<span class="version">v0.2</span>
|
<div class="header-right">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<input type="search" id="search-input"
|
||||||
|
hx-get="/search"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#search-results"
|
||||||
|
name="q"
|
||||||
|
placeholder="Pretraži..."
|
||||||
|
autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results-dropdown"></div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
|
||||||
|
<button class="theme-btn" data-theme-mode="dark" onclick="setTheme('dark')" title="Tamna tema">🌙</button>
|
||||||
|
<button class="theme-btn" data-theme-mode="auto" onclick="setTheme('auto')" title="Sistemska tema">🔄</button>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn btn-active">Kanban</a>
|
||||||
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
|
<a href="#" class="btn" onclick="showLogs(); return false;">Logovi</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
<div id="task-detail"></div>
|
<div id="task-detail"></div>
|
||||||
@ -29,6 +54,39 @@ function closeDetail() {
|
|||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close overlay on Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var detail = document.getElementById('task-detail');
|
||||||
|
if (detail && detail.classList.contains('active')) {
|
||||||
|
closeDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close overlay on backdrop click
|
||||||
|
document.getElementById('task-detail').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeDetail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showLogs() {
|
||||||
|
fetch('/api/logs/tail')
|
||||||
|
.then(function(resp) { return resp.text(); })
|
||||||
|
.then(function(text) {
|
||||||
|
var el = document.getElementById('task-detail');
|
||||||
|
el.innerHTML = '<div class="detail-overlay"><div class="detail-header"><h2>Poslednji logovi</h2><button class="detail-close" onclick="closeDetail()">×</button></div><div class="detail-body"><pre style="white-space:pre-wrap;font-size:13px;line-height:1.5">' + escapeHtml(text) + '</pre></div></div>';
|
||||||
|
el.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(msg, type) {
|
function showToast(msg, type) {
|
||||||
var toast = document.getElementById('toast');
|
var toast = document.getElementById('toast');
|
||||||
toast.textContent = msg;
|
toast.textContent = msg;
|
||||||
@ -38,10 +96,75 @@ function showToast(msg, type) {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle "Proveri" button response
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||||
|
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/review/)) {
|
||||||
|
var xhr = e.detail.xhr;
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
showToast('Pregled pokrenut', 'success');
|
||||||
|
setTimeout(function() { window.location.href = '/console'; }, 800);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(xhr.responseText);
|
||||||
|
showToast(data.error || 'Greška', 'error');
|
||||||
|
} catch(ex) {
|
||||||
|
showToast('Greška', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle "Pusti" button response
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||||
|
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) {
|
||||||
|
var xhr = e.detail.xhr;
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
var data = JSON.parse(xhr.responseText);
|
||||||
|
showToast('Pokrenuto: ' + (data.task || ''), 'success');
|
||||||
|
setTimeout(function() { window.location.href = '/console'; }, 800);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(xhr.responseText);
|
||||||
|
showToast(data.error || 'Greška', 'error');
|
||||||
|
} catch(ex) {
|
||||||
|
showToast('Greška', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var isDragging = false;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initSortable();
|
initSortable();
|
||||||
|
initSSE();
|
||||||
|
|
||||||
|
// Close search results on click outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var wrapper = document.querySelector('.search-wrapper');
|
||||||
|
var results = document.getElementById('search-results');
|
||||||
|
if (wrapper && !wrapper.contains(e.target)) {
|
||||||
|
results.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function initSSE() {
|
||||||
|
var source = new EventSource('/events');
|
||||||
|
source.addEventListener('taskUpdate', function(e) {
|
||||||
|
if (isDragging) return;
|
||||||
|
var board = document.getElementById('board');
|
||||||
|
if (board) {
|
||||||
|
board.outerHTML = e.data;
|
||||||
|
initSortable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
source.onerror = function() {
|
||||||
|
// EventSource auto-reconnects
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(e) {
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
if (e.detail.target.id === 'board') {
|
if (e.detail.target.id === 'board') {
|
||||||
initSortable();
|
initSortable();
|
||||||
@ -57,7 +180,9 @@ function initSortable() {
|
|||||||
chosenClass: 'task-chosen',
|
chosenClass: 'task-chosen',
|
||||||
dragClass: 'task-drag',
|
dragClass: 'task-drag',
|
||||||
filter: '.column-header',
|
filter: '.column-header',
|
||||||
|
onStart: function() { isDragging = true; },
|
||||||
onEnd: function(evt) {
|
onEnd: function(evt) {
|
||||||
|
isDragging = false;
|
||||||
var taskId = evt.item.dataset.id;
|
var taskId = evt.item.dataset.id;
|
||||||
var toFolder = evt.to.dataset.folder;
|
var toFolder = evt.to.dataset.folder;
|
||||||
var fromFolder = evt.from.dataset.folder;
|
var fromFolder = evt.from.dataset.folder;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
{{define "column"}}
|
{{define "column"}}
|
||||||
<div class="column" id="col-{{.Name}}"
|
<div class="column" id="col-{{.Name}}">
|
||||||
{{if eq .Name "active"}}hx-get="/" hx-trigger="every 5s" hx-select="#col-active" hx-target="#col-active" hx-swap="outerHTML"{{end}}>
|
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<span>{{.Icon}} {{.Label}}</span>
|
<span>{{.Icon}} {{.Label}}</span>
|
||||||
<span class="column-count">{{.Count}}</span>
|
<span class="column-count">{{.Count}}</span>
|
||||||
|
|||||||
16
code/web/templates/partials/search-results.html
Normal file
16
code/web/templates/partials/search-results.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{{define "search-results"}}
|
||||||
|
{{if .Results}}
|
||||||
|
{{range .Results}}
|
||||||
|
<a href="{{.Link}}" class="search-result" {{if eq .Type "task"}}hx-get="{{.Link}}" hx-target="#task-detail" hx-swap="innerHTML"{{end}}>
|
||||||
|
<div class="search-result-header">
|
||||||
|
<span class="search-icon">{{.Icon}}</span>
|
||||||
|
<span class="search-title">{{.Title}}</span>
|
||||||
|
{{if .Status}}<span class="search-status search-status-{{.Status}}">{{.Status}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="search-snippet">{{.Snippet}}</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div class="search-empty">Nema rezultata za "{{.Query}}"</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@ -6,5 +6,20 @@
|
|||||||
{{if .DependsOn}}
|
{{if .DependsOn}}
|
||||||
<div class="task-deps">Zavisi od: {{joinDeps .DependsOn}}</div>
|
<div class="task-deps">Zavisi od: {{joinDeps .DependsOn}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<div class="task-action">
|
||||||
|
{{if eq .Action "blocked"}}
|
||||||
|
<span class="btn btn-blocked">Blokiran</span>
|
||||||
|
{{else if eq .Action "review"}}
|
||||||
|
<button class="btn btn-review" hx-get="/task/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">Pregledaj</button>
|
||||||
|
{{else if eq .Action "run"}}
|
||||||
|
<button class="btn btn-run" hx-post="/task/{{.ID}}/run" hx-target="#toast" hx-swap="none" onclick="event.stopPropagation()">Pusti</button>
|
||||||
|
{{else if eq .Action "running"}}
|
||||||
|
<span class="btn btn-running">Radi</span>
|
||||||
|
{{else if eq .Action "approve"}}
|
||||||
|
<button class="btn btn-approve" hx-get="/task/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">Pregledaj</button>
|
||||||
|
{{else if eq .Action "done"}}
|
||||||
|
<button class="btn btn-report" hx-get="/report/{{.ID}}" hx-target="#task-detail" hx-swap="innerHTML" onclick="event.stopPropagation()">📊 Izveštaj</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -1,25 +1,31 @@
|
|||||||
{{define "task-detail"}}
|
{{define "task-detail"}}
|
||||||
<span class="detail-close" onclick="closeDetail()">✕</span>
|
<div class="detail-inner">
|
||||||
<h2>{{.Task.ID}}: {{.Task.Title}}</h2>
|
<span class="detail-close" onclick="closeDetail()">✕</span>
|
||||||
<div class="detail-meta">
|
<h2>{{.Task.ID}}: {{.Task.Title}}</h2>
|
||||||
<p><strong>Agent:</strong> {{.Task.Agent}} · <strong>Model:</strong> {{.Task.Model}} · <strong>Status:</strong> {{.Task.Status}}</p>
|
<div class="detail-meta">
|
||||||
{{if .Task.DependsOn}}
|
<p><strong>Agent:</strong> {{.Task.Agent}} · <strong>Model:</strong> {{.Task.Model}} · <strong>Status:</strong> {{.Task.Status}}</p>
|
||||||
<p><strong>Zavisi od:</strong> {{joinDeps .Task.DependsOn}}</p>
|
{{if .Task.DependsOn}}
|
||||||
|
<p><strong>Zavisi od:</strong> {{joinDeps .Task.DependsOn}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if or .HasReport (eq .Task.Status "done")}}
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn btn-report" hx-get="/report/{{.Task.ID}}" hx-target="#task-detail" hx-swap="innerHTML">📊 Izveštaj</button>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
<div class="detail-actions">
|
||||||
{{if .HasReport}}
|
{{if eq .Task.Status "backlog"}}
|
||||||
<div class="detail-actions">
|
<button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button>
|
||||||
<a href="/report/{{.Task.ID}}" class="btn" target="_blank">📝 Izveštaj</a>
|
{{end}}
|
||||||
|
{{if eq .Task.Status "ready"}}
|
||||||
|
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/run" hx-swap="none" onclick="closeDetail()">Pusti</button>
|
||||||
|
{{end}}
|
||||||
|
{{if eq .Task.Status "review"}}
|
||||||
|
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/review" hx-swap="none" onclick="closeDetail()">Proveri</button>
|
||||||
|
<button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=done" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button>
|
||||||
|
<button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Vrati</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="detail-content docs-content">{{.Content}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="detail-actions">
|
|
||||||
{{if eq .Task.Status "backlog"}}
|
|
||||||
<button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">📋 Premesti u Ready</button>
|
|
||||||
{{end}}
|
|
||||||
{{if eq .Task.Status "review"}}
|
|
||||||
<button class="btn btn-move btn-success" hx-post="/task/{{.Task.ID}}/move?to=done" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">✅ Odobri (Done)</button>
|
|
||||||
<button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">🔄 Vrati u Ready</button>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="detail-content">{{.Content}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
164
code/web/templates/submit.html
Normal file
164
code/web/templates/submit.html
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
{{define "submit"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KAOS — Prijava</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔧 KAOS Dashboard</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn">Kanban</a>
|
||||||
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn btn-active">Prijava</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="submit-container">
|
||||||
|
<div class="submit-mode-toggle">
|
||||||
|
<button class="btn btn-mode active" id="btn-client" onclick="switchMode('client')">👤 Klijent</button>
|
||||||
|
<button class="btn btn-mode" id="btn-operator" onclick="switchMode('operator')">🔧 Operater</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client mode -->
|
||||||
|
<div id="mode-client" class="submit-mode">
|
||||||
|
<h2>📝 Nova prijava</h2>
|
||||||
|
<form id="client-form" onsubmit="submitClientForm(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Naslov:</label>
|
||||||
|
<input type="text" name="title" id="submit-title" class="form-input" placeholder="Kratak opis problema ili ideje..." required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Opis (opciono):</label>
|
||||||
|
<textarea name="description" id="submit-desc" class="form-input" rows="6" placeholder="Detaljniji opis..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Prioritet:</label>
|
||||||
|
<div class="priority-group">
|
||||||
|
<label class="priority-label"><input type="radio" name="priority" value="Nizak"> Nizak</label>
|
||||||
|
<label class="priority-label"><input type="radio" name="priority" value="Srednji" checked> Srednji</label>
|
||||||
|
<label class="priority-label"><input type="radio" name="priority" value="Visok"> Visok</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">Pošalji 📨</button>
|
||||||
|
</form>
|
||||||
|
<div id="client-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operator mode -->
|
||||||
|
<div id="mode-operator" class="submit-mode" style="display:none">
|
||||||
|
<h2>🔧 Operater mod</h2>
|
||||||
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input type="text" id="chat-input" class="console-input" placeholder="Piši..." onkeydown="if(event.key==='Enter')sendChat()" autocomplete="off">
|
||||||
|
<button class="btn btn-move" onclick="sendChat()">⏎</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var currentChatID = '';
|
||||||
|
|
||||||
|
function switchMode(mode) {
|
||||||
|
document.getElementById('mode-client').style.display = mode === 'client' ? 'block' : 'none';
|
||||||
|
document.getElementById('mode-operator').style.display = mode === 'operator' ? 'flex' : 'none';
|
||||||
|
document.getElementById('btn-client').classList.toggle('active', mode === 'client');
|
||||||
|
document.getElementById('btn-operator').classList.toggle('active', mode === 'operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitClientForm(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var form = document.getElementById('client-form');
|
||||||
|
var data = new FormData(form);
|
||||||
|
|
||||||
|
fetch('/submit/simple', {method: 'POST', body: data})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var el = document.getElementById('client-result');
|
||||||
|
if (data.error) {
|
||||||
|
el.innerHTML = '<div class="submit-msg submit-error">' + escapeHtml(data.error) + '</div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<div class="submit-msg submit-success">✅ ' + data.task_id + ' kreiran u backlog/</div>';
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendChat() {
|
||||||
|
var input = document.getElementById('chat-input');
|
||||||
|
var msg = input.value.trim();
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
var messages = document.getElementById('chat-messages');
|
||||||
|
messages.innerHTML += '<div class="chat-msg chat-user"><span class="chat-role">👤</span> ' + escapeHtml(msg) + '</div>';
|
||||||
|
input.value = '';
|
||||||
|
input.disabled = true;
|
||||||
|
|
||||||
|
var botId = 'bot-' + Date.now();
|
||||||
|
messages.innerHTML += '<div class="chat-msg chat-bot" id="' + botId + '"><span class="chat-role">🤖</span> <span class="chat-text">...</span></div>';
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
|
||||||
|
fetch('/submit/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({message: msg})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
document.getElementById(botId).querySelector('.chat-text').textContent = 'Greška: ' + data.error;
|
||||||
|
input.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentChatID = data.chat_id;
|
||||||
|
streamChatResponse(data.chat_id, botId);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
document.getElementById(botId).querySelector('.chat-text').textContent = 'Greška: ' + err.message;
|
||||||
|
input.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamChatResponse(chatID, botId) {
|
||||||
|
var source = new EventSource('/submit/chat/stream/' + chatID);
|
||||||
|
var el = document.getElementById(botId);
|
||||||
|
var input = document.getElementById('chat-input');
|
||||||
|
var lines = [];
|
||||||
|
|
||||||
|
source.onmessage = function(e) {
|
||||||
|
if (el) {
|
||||||
|
lines.push(e.data);
|
||||||
|
el.querySelector('.chat-text').textContent = lines.join('\n');
|
||||||
|
var messages = document.getElementById('chat-messages');
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener('done', function() {
|
||||||
|
source.close();
|
||||||
|
input.disabled = false;
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
source.onerror = function() {
|
||||||
|
source.close();
|
||||||
|
input.disabled = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
159
docs/ARCHITECTURE.md
Normal file
159
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# KAOS — Arhitektura
|
||||||
|
|
||||||
|
**Verzija:** 0.3.0
|
||||||
|
**Poslednje azuriranje:** 2026-02-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Komponenta | Tehnologija |
|
||||||
|
|------------|-------------|
|
||||||
|
| Backend | Go 1.22+ |
|
||||||
|
| HTTP framework | Gin |
|
||||||
|
| Frontend | Go templates + HTMX + xterm.js |
|
||||||
|
| Terminali | xterm.js 5.5.0 (CDN) + WebSocket |
|
||||||
|
| PTY | github.com/creack/pty |
|
||||||
|
| WebSocket | github.com/gorilla/websocket |
|
||||||
|
| Markdown | github.com/yuin/goldmark |
|
||||||
|
| Drag & Drop | Sortable.js |
|
||||||
|
| Real-time | Server-Sent Events (SSE) |
|
||||||
|
| Baza | Nema (disk je source of truth) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struktura koda
|
||||||
|
|
||||||
|
```
|
||||||
|
code/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── kaos-server/ # HTTP server entry point
|
||||||
|
│ └── kaos-supervisor/ # CLI supervisor (legacy)
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # Konfiguracija (env vars)
|
||||||
|
│ ├── server/ # HTTP handleri, PTY, WS, render
|
||||||
|
│ └── supervisor/ # Task scanner, file ops
|
||||||
|
├── web/
|
||||||
|
│ ├── static/ # CSS, JS (htmx, sortable, theme)
|
||||||
|
│ └── templates/ # Go templates (go:embed)
|
||||||
|
│ ├── layout.html
|
||||||
|
│ ├── console.html
|
||||||
|
│ └── partials/
|
||||||
|
│ ├── task-card.html
|
||||||
|
│ └── task-detail.html
|
||||||
|
├── go.mod
|
||||||
|
├── go.sum
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kljucne komponente
|
||||||
|
|
||||||
|
### Server (server.go)
|
||||||
|
- Gin router sa svim rutama
|
||||||
|
- No-cache middleware za dinamicke rute
|
||||||
|
- SSE event broker za real-time update
|
||||||
|
- Task session manager za PTY sesije
|
||||||
|
|
||||||
|
### Task Session Manager (console.go)
|
||||||
|
- `taskSessionManager` - mapa PTY sesija po task ID
|
||||||
|
- `startSession()` - spawna interaktivni claude u PTY
|
||||||
|
- Prompt se pise u temp fajl, claude dobija jednolinijsku instrukciju
|
||||||
|
- Sesije prezivljavaju page reload (PTY na serveru)
|
||||||
|
|
||||||
|
### PTY Session (pty_session.go)
|
||||||
|
- `consolePTYSession` - wrapper oko PTY procesa
|
||||||
|
- RingBuffer (1MB) za replay output-a
|
||||||
|
- Subscriber pattern za vise WS klijenata
|
||||||
|
- `spawnTaskPTY()` - claude --permission-mode dontAsk
|
||||||
|
|
||||||
|
### WebSocket Handler (ws.go)
|
||||||
|
- Konektuje se na postojecu PTY sesiju po kljucu
|
||||||
|
- Replay buffer na konekciju
|
||||||
|
- Bidirekcioni: PTY output -> browser, keyboard -> PTY
|
||||||
|
- Resize podrska
|
||||||
|
|
||||||
|
### Render Engine (render.go)
|
||||||
|
- Go templates sa go:embed
|
||||||
|
- Dashboard, task detail, report modal
|
||||||
|
- Markdown -> HTML sa goldmark
|
||||||
|
- Docs sa sidebar layoutom
|
||||||
|
|
||||||
|
### SSE Events (events.go)
|
||||||
|
- Event broker sa poll intervalom
|
||||||
|
- Hash task stanja za detekciju promena
|
||||||
|
- Broadcast samo kad se nesto promeni
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dijagram toka - "Pusti" dugme
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser: klik "Pusti"
|
||||||
|
|
|
||||||
|
v
|
||||||
|
POST /task/T08/run
|
||||||
|
|
|
||||||
|
v
|
||||||
|
handleRunTask:
|
||||||
|
1. Validacija (status, deps)
|
||||||
|
2. MoveTask ready -> active
|
||||||
|
3. appendTimestamp
|
||||||
|
4. Cita task sadrzaj
|
||||||
|
5. buildWorkPrompt -> temp fajl
|
||||||
|
6. startSession(T08, "work", ...)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
spawnTaskPTY -> claude --permission-mode dontAsk
|
||||||
|
|
|
||||||
|
v
|
||||||
|
goroutine: ceka first output, salje prompt
|
||||||
|
|
|
||||||
|
v
|
||||||
|
JSON response {status: started, task: T08}
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Browser: redirect /console
|
||||||
|
|
|
||||||
|
v
|
||||||
|
refreshSessions() -> GET /console/sessions
|
||||||
|
|
|
||||||
|
v
|
||||||
|
createTerminal(T08) -> WS /console/ws/T08
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Replay buffer + live output
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment varijable
|
||||||
|
|
||||||
|
| Varijabla | Opis | Primer |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| KAOS_PORT | HTTP port | 8080 |
|
||||||
|
| KAOS_PROJECT_PATH | Root projekta | /root/projects/KAOS |
|
||||||
|
| KAOS_TASKS_DIR | Tasks folder | /root/projects/KAOS/TASKS |
|
||||||
|
| KAOS_TIMEOUT | Timeout za agente | 300s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
Testovi su razdvojeni po oblasti:
|
||||||
|
|
||||||
|
| Fajl | Oblast |
|
||||||
|
|------|--------|
|
||||||
|
| test_helpers_test.go | Deljeni setup i helper funkcije |
|
||||||
|
| api_test.go | REST API endpointi |
|
||||||
|
| dashboard_test.go | Dashboard HTML rendering |
|
||||||
|
| task_detail_test.go | Task detail, report, run |
|
||||||
|
| docs_test.go | Dokumenti stranica |
|
||||||
|
| search_test.go | Pretraga |
|
||||||
|
| submit_test.go | Prijava taskova |
|
||||||
|
| sse_test.go | Server-Sent Events |
|
||||||
|
| console_test.go | Konzola, sesije, prompt builderi |
|
||||||
|
| ui_test.go | UI ponasanje |
|
||||||
|
| timestamp_test.go | Vremena, PTY, RingBuffer |
|
||||||
|
| logs_test.go | Server logovi |
|
||||||
104
docs/SETUP.md
Normal file
104
docs/SETUP.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# KAOS — Setup
|
||||||
|
|
||||||
|
**Poslednje azuriranje:** 2026-02-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zahtevi
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- claude CLI (instaliran i dostupan u PATH)
|
||||||
|
- Linux (PTY podrska)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pokretanje
|
||||||
|
|
||||||
|
### 1. Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/projects/KAOS/code
|
||||||
|
go build -o kaos-server ./cmd/kaos-server/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KAOS_PORT=8080
|
||||||
|
export KAOS_PROJECT_PATH=/root/projects/KAOS
|
||||||
|
export KAOS_TASKS_DIR=/root/projects/KAOS/TASKS
|
||||||
|
export KAOS_TIMEOUT=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nohup ./kaos-server > /tmp/kaos-server.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Provera
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/projects/KAOS/code
|
||||||
|
|
||||||
|
# Svi testovi
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# Samo server testovi
|
||||||
|
go test ./internal/server/ -count=1 -v
|
||||||
|
|
||||||
|
# Build + vet
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task folderi
|
||||||
|
|
||||||
|
```
|
||||||
|
TASKS/
|
||||||
|
├── backlog/ # Novi taskovi
|
||||||
|
├── ready/ # Odobreni za rad
|
||||||
|
├── active/ # U izradi
|
||||||
|
├── review/ # Ceka pregled
|
||||||
|
├── done/ # Zavrseno
|
||||||
|
└── reports/ # Izvestaji
|
||||||
|
```
|
||||||
|
|
||||||
|
Svaki task je markdown fajl (npr. `T08.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struktura task fajla
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# T08: Naziv taska
|
||||||
|
|
||||||
|
**Kreirao:** planer
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Sonnet
|
||||||
|
**Zavisi od:** T07
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opis
|
||||||
|
|
||||||
|
Sta treba da se uradi.
|
||||||
|
|
||||||
|
## Vremena
|
||||||
|
|
||||||
|
| Dogadjaj | Vreme |
|
||||||
|
|---------|-------|
|
||||||
|
| Odobren (->ready) | 2026-02-20 14:00 |
|
||||||
|
| Pokrenut (->active) | 2026-02-20 14:05 |
|
||||||
|
```
|
||||||
106
docs/SPEC.md
Normal file
106
docs/SPEC.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# KAOS — Specifikacija
|
||||||
|
|
||||||
|
**Verzija:** 0.3.0
|
||||||
|
**Poslednje azuriranje:** 2026-02-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pregled
|
||||||
|
|
||||||
|
KAOS je sistem za razvoj softvera sa AI agentima pod ljudskim nadzorom.
|
||||||
|
Web dashboard omogucava operateru da upravlja taskovima, pokrece claude agente,
|
||||||
|
i prati rad u realnom vremenu kroz konzolne terminale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funkcionalnosti
|
||||||
|
|
||||||
|
### Kanban Board
|
||||||
|
- 5 kolona: Backlog, Ready, Active, Review, Done
|
||||||
|
- Drag & drop premestanje taskova (Sortable.js)
|
||||||
|
- SSE auto-refresh kad se stanje promeni
|
||||||
|
- Dugmad po statusu: Odobri, Pusti, Proveri, Izvestaj
|
||||||
|
- Task detail modal sa markdown renderovanjem
|
||||||
|
|
||||||
|
### Konzola (Task PTY Sessions)
|
||||||
|
- Svaki task dobija sopstvenu claude CLI sesiju u PTY
|
||||||
|
- "Pusti" dugme: premesta task u active, pokrece interaktivni claude
|
||||||
|
- "Proveri" dugme: pokrece review claude sesiju za task u review/
|
||||||
|
- Terminali u browseru via xterm.js + WebSocket
|
||||||
|
- Replay buffer (1MB ring buffer) za reconnect
|
||||||
|
- Sesije prezivljavaju page reload
|
||||||
|
- Dinamicko dodavanje/brisanje terminala po aktivnim sesijama
|
||||||
|
- Kill dugme za zavrsavanje sesije
|
||||||
|
|
||||||
|
### Pretraga
|
||||||
|
- Pretrazuje taskove, dokumente i izvestaje
|
||||||
|
- Case-insensitive, sa snippet kontekstom
|
||||||
|
- Real-time rezultati (htmx delay:300ms)
|
||||||
|
|
||||||
|
### Dokumenti
|
||||||
|
- Pregled svih .md fajlova u projektu
|
||||||
|
- Sidebar + main layout
|
||||||
|
- Markdown renderovanje sa tabelama
|
||||||
|
- Breadcrumbs navigacija
|
||||||
|
- HTMX fragmenti za client-side navigaciju
|
||||||
|
|
||||||
|
### Prijava taskova
|
||||||
|
- Klijent mod: jednostavna forma (naslov + opis + prioritet)
|
||||||
|
- Operater mod: chat sa claude CLI za kreiranje taska
|
||||||
|
|
||||||
|
### Server logovi
|
||||||
|
- GET /api/logs/tail - poslednjih 100 linija loga
|
||||||
|
- Modal prikaz na dashboardu
|
||||||
|
|
||||||
|
### Vremena (Timestamps)
|
||||||
|
- Svaki prelaz taska belexi timestamp u Vremena tabelu
|
||||||
|
- Automatski u task .md fajl
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
backlog --> ready --> active --> review --> done
|
||||||
|
| | |
|
||||||
|
<---------- |
|
||||||
|
<-------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prelaz | Ko | Kako |
|
||||||
|
|--------|----|------|
|
||||||
|
| backlog -> ready | operater | Odobri dugme |
|
||||||
|
| ready -> active | server | Pusti dugme (automatski) |
|
||||||
|
| active -> review | claude agent | Kad zavrsi rad |
|
||||||
|
| review -> done | operater/checker | Odobri ili Proveri |
|
||||||
|
| review -> active | operater/checker | Vrati na doradu |
|
||||||
|
| ready -> backlog | operater | Vrati dugme |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API endpointi
|
||||||
|
|
||||||
|
| Metod | Putanja | Opis |
|
||||||
|
|-------|---------|------|
|
||||||
|
| GET | / | Dashboard (Kanban) |
|
||||||
|
| GET | /api/tasks | Svi taskovi (JSON) |
|
||||||
|
| GET | /api/task/:id | Detalj taska (JSON) |
|
||||||
|
| POST | /api/task/:id/move?to= | Premesti task |
|
||||||
|
| GET | /task/:id | Task detail (HTML) |
|
||||||
|
| POST | /task/:id/move?to= | Premesti + vrati board |
|
||||||
|
| POST | /task/:id/run | Pusti task (active + claude) |
|
||||||
|
| POST | /task/:id/review | Pokreni review sesiju |
|
||||||
|
| GET | /report/:id | Izvestaj/task modal |
|
||||||
|
| GET | /events | SSE stream |
|
||||||
|
| GET | /search?q= | Pretraga |
|
||||||
|
| GET | /console | Konzola stranica |
|
||||||
|
| GET | /console/sessions | Aktivne sesije (JSON) |
|
||||||
|
| POST | /console/kill/:taskID | Ubij sesiju |
|
||||||
|
| GET | /console/ws/:key | WebSocket terminal |
|
||||||
|
| GET | /api/logs/tail | Server logovi |
|
||||||
|
| GET | /docs | Lista dokumenata |
|
||||||
|
| GET | /docs/*path | Pregled dokumenta |
|
||||||
|
| GET | /submit | Prijava stranica |
|
||||||
|
| POST | /submit/simple | Prijava forme |
|
||||||
|
| POST | /submit/chat | Operater chat |
|
||||||
|
| GET | /submit/chat/stream/:id | Chat SSE stream |
|
||||||
Loading…
Reference in New Issue
Block a user