Compare commits

..

21 Commits
v0.3.2 ... main

Author SHA1 Message Date
djuka
e81eade2e1 T25: Izveštaj dugme za done taskove + testovi za timestampe
Fix: done taskovi bez reporta sada prikazuju "Izveštaj" dugme.
Dodato 10 novih testova za timestamp tracking i report prikaz.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:51:40 +00:00
djuka
098ed13705 T22: Reorganizacija testova + logs handler + konzola fix
- Razbijen monolitni server_test.go na fokusirane test fajlove:
  api_test.go, dashboard_test.go, docs_test.go, search_test.go,
  submit_test.go, task_detail_test.go, console_test.go, sse_test.go,
  timestamp_test.go, ui_test.go, test_helpers_test.go
- Dodat logs.go handler (handleLogsTail) koji je nedostajao
- Dodat LogFile u config
- Fix konzola: prompt se šalje preko fajla umesto direktno u PTY
- 192 testova prolazi

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:45:50 +00:00
djuka
b739ef1fb7 T26: Izveštaj i dokumentacija
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:35:56 +00:00
djuka
4031593ea8 T26: Testovi za endpoint zadnjih 20 linija loga
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:35:20 +00:00
djuka
510b75c0bf Konzola: dinamičke task sesije sa PTY per task
- Zamena fiksnih 2 sesija sa taskSessionManager (map po task ID)
- "Pusti" pokreće interaktivni claude u PTY, šalje task prompt
- "Proveri" pokreće review claude sesiju za task u review/
- WS se konektuje na postojeću PTY sesiju po task ID-u
- Konzola stranica dinamički prikazuje terminale za aktivne sesije
- Replay buffer za reconnect na postojeće sesije
- Novi testovi za session manager, prompt buildere, review endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:32:34 +00:00
djuka
ac72ca6f52 Pusti: premešta task ready→active bez pokretanja claude sesije
- handleRunTask samo premešta task iz ready/ u active/ sa timestampom
- Uklonjena zavisnost od console sesija — konzola je nezavisna
- Korisnik pokreće claude ručno iz konzole terminala
- Ažurirani testovi (6 RunTask testova prolaze)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:09:30 +00:00
djuka
932ffe5203 Konzola: interaktivni claude CLI + panic fix
- Svaka konzola sesija pokreće interaktivni claude (ne bash)
- Fix panic: send on closed channel kad se WS diskonektuje
- Tema: Claude Code boje (#0d1117 pozadina)
- PTY readLoop logging za debug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:04:39 +00:00
djuka
d27eb900b1 Konzola: sklonjen command input, detaljno WS logovanje
- Uklonjena input polja iz konzole — rad samo kroz Pusti dugme
- Detaljno logovanje WS: connect, poll, subscribe, buffer, disconnect
- WS timeout 30s ako nema PTY sesije
- Provera da li je PTY already done pre nego subscribe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:52:51 +00:00
djuka
fa8aa59b29 Fix konzola: race condition PTY + logging start/finish
- Ne briše ptySess iz sesije po završetku — WS handler ga koristi za replay
- WS handler šalje close frame kad proces završi
- Logovanje: PTY spawned (PID) + PTY finished (status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:48:00 +00:00
djuka
64df1e784c Fix konzola: auto-connect, welcome poruka, redirect posle Pusti
- Auto-connect WS na running sesije kad se otvori /console
- Welcome poruka u terminalu kad nema aktivne sesije
- Redirect na /console posle uspešnog "Pusti" klika
- CSS fix: min-height i position za xterm kontejner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:40:00 +00:00
djuka
c970cb2419 Konzola: xterm.js + WebSocket + PTY real-time terminal
- Nova pty_session.go: RingBuffer, consolePTYSession, spawnConsolePTY
- Nova ws.go: WebSocket handler za PTY bidirekcioni I/O
- console.go: koristi consolePTYSession umesto starih pipe-ova
- console.html: xterm.js 5.5.0 CDN, FitAddon, WebLinksAddon
- Podrška za resize, binarni podaci, replay buffer (1MB)
- 8 novih testova (RingBuffer + xterm konzola) — ukupno 179

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:31:27 +00:00
djuka
7cce5e99c7 T25: Timestamp tracking + izveštaj prikaz u overlay modalu
1. Timestamp: svaki potez taska (move/run) dodaje red u tabelu
   "## Vremena" u task fajlu sa događajem i vremenom.
2. Izveštaj: klik "Izveštaj" na done tasku otvara overlay modal
   sa goldmark-renderovanim reportom. Ako nema reporta, prikazuje
   sam task sadržaj.

10 novih testova, 172 ukupno — svi prolaze.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:17:51 +00:00
djuka
003650df24 T24: PTY za konzolu i operater chat — real-time streaming
Konzola i operater chat sada koriste pseudo-terminal (PTY) umesto
pipe-a. Claude CLI detektuje terminal i šalje output odmah umesto
da bufferuje. ANSI escape sekvence se uklanjaju pre slanja kroz SSE.

Novi fajl: pty.go (startPTY, readPTY, stripAnsi)
Biblioteka: github.com/creack/pty v1.1.24
5 novih testova za PTY funkcionalnost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:13:13 +00:00
djuka
41beccab7e Revert: CLAUDE.md menja samo planer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:07:06 +00:00
djuka
5e86421100 T22: Zamena bypassPermissions sa dontAsk — radi kao root
bypassPermissions je interno blokiran za root isto kao
--dangerously-skip-permissions. dontAsk automatski odobrava
sve bez te provere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:05:11 +00:00
djuka
7efc92feac T22: Zamena --dangerously-skip-permissions sa --permission-mode bypassPermissions
Stari flag ne radi kad je proces pokrenut kao root. Zamenjeno u
console.go, submit.go i CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:00:28 +00:00
djuka
695bd24d1d Fix: Unset CLAUDECODE env var za child claude procese
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:55:13 +00:00
djuka
80cf1d73ce Fix: Operater mod koristi claude CLI umesto API poziva
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:50:01 +00:00
djuka
23f0fba6ec Fix: Task detalj renderuje markdown kao HTML kroz goldmark
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:48:19 +00:00
djuka
5bf7375b50 Fix: Task detalj kao overlay modal umesto side panela
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:43:31 +00:00
djuka
b3645beea0 T22: Prijava — dva moda (klijent forma + operater chat sa Claude API)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:38:05 +00:00
43 changed files with 5444 additions and 2351 deletions

229
CLAUDE.md
View File

@ -1,71 +1,81 @@
# KAOS — Mastermind
**Verzija:** 0.3.0
**Poslednje ažuriranje:** 2026-02-20
**Verzija:** 0.6.0
**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
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"
2. Pogledaj `TASKS/ready/` — ima li task spreman za rad
3. Pogledaj `TASKS/review/` — ima li task sa dopunjenim odgovorima
4. Pokaži operateru šta ćeš da radiš — **čekaj odobrenje**
5. Kad dobiješ ok — izvršavaj
### 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"
- Build/test/vet moraju proći
- Commit: `T{XX}: Opis na srpskom`
- Push + tag (semver) + push tags
- Izveštaj u `TASKS/reports/T{XX}-report.md`
- Premesti task u `review/`
### NIKAD
- Ne radi bez odobrenja operatera
- Ne pretpostavljaj šta operater želi
- Ne preskoči "čekaj odobrenje"
- Ne radi na tasku koji nije u `ready/` ili `review/`
---
## Task folderi
## Ko šta sme da premesti
```
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) |
|---------|---------------------|-------------|
| Iz → U | Operater | Agent |
|---------|----------|-------|
| backlog → ready | ✅ | ❌ |
| ready → backlog | ✅ | ❌ |
| ready → active | ❌ | ✅ |
@ -74,93 +84,68 @@ TASKS/
| review → ready | ✅ | ❌ |
| done → bilo gde | ❌ | ❌ |
Server validira. Nedozvoljen potez → 403.
Deployer jedini KREIRA taskove u backlog/ (greške iz logova).
---
## Struktura projekta
## Struktura
```
/root/projects/KAOS/
├── CLAUDE.md ← OVO — mastermind (v0.3.0)
├── README.md
├── agents/ ← specijalizovani agenti
│ ├── triage/CLAUDE.md
│ ├── task-manager/CLAUDE.md
│ ├── 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
/opt/kaos/
├── CLAUDE.md ← OVO
├── agents/*/CLAUDE.md ← pravila po agentu
├── TASKS/ ← backlog/ready/active/review/done/reports/
├── code/ ← Go + HTMX (jedan binary, nema npm)
├── logs/ ← persistent logovi (planirano)
├── documentation/ ← eksterna dokumentacija
└── templates/new-project/ ← template za nove projekte
```
---
## 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 | Folder | Model | Verzija |
|-------|--------|-------|---------|
| Triage | agents/triage/ | Haiku | 0.1.0 |
| Task Manager | agents/task-manager/ | Sonnet/Haiku | 0.1.0 |
| Coder | agents/coder/ | Sonnet/Opus | 0.2.0 |
| Frontend | agents/frontend/ | Sonnet | 0.2.0 |
| Checker | agents/checker/ | Haiku/Opus | 0.1.0 |
| Reporter | agents/reporter/ | Haiku | 0.1.0 |
| Docs | agents/docs/ | Haiku | 0.1.0 |
| Deployer | agents/deployer/ | Haiku | 0.1.0 |
| Agent | Model | Verzija | Detalji |
|-------|-------|---------|---------|
| Triage | Haiku | 0.1.0 | `agents/triage/CLAUDE.md` |
| Task Manager | Sonnet/Haiku | 0.1.0 | `agents/task-manager/CLAUDE.md` |
| Coder | Sonnet/Opus | 0.2.0 | `agents/coder/CLAUDE.md` |
| Frontend | Sonnet | 0.2.0 | `agents/frontend/CLAUDE.md` |
| Checker | Haiku/Opus | 0.1.0 | `agents/checker/CLAUDE.md` |
| Reporter | Haiku | 0.1.0 | `agents/reporter/CLAUDE.md` |
| Docs | Haiku | 0.1.0 | `agents/docs/CLAUDE.md` |
| 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.
| Zadatak | Agent | Model | Cena/M tokena |
|---------|-------|-------|---------------|
| 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 |
Semver. Patch = task, Minor = milestone, Major = breaking change.
Git: commit → push → tag → push tags. Format: `T{XX}: Opis`
Timeout: 30 min default (KAOS_TIMEOUT u .env).
---
## Pristup
## Reference
| Folder | Čita | Piše |
|--------|------|------|
| agents/ | ✅ | ❌ |
| TASKS/ | ✅ | ✅ (status, premesti fajlove) |
| documentation/ | ✅ | ❌ |
| code/ | ✅ | ✅ (kad izvršava task) |
---
## 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.
| Šta | Gde |
|-----|-----|
| Status svih taskova + bugovi | `TASKS/MASTER-STATUS.md` |
| Kompletan workflow | `TASKS/Workflow-Spec.md` |
| Multi-agent arhitektura | `TASKS/Multi-Agent-Spec.md` |
| Format izveštaja | `TASKS/reports/T01-report.md` (primer) |
| Template za novi projekat | `templates/new-project/` |

View File

@ -1,9 +1,9 @@
# KAOS — AI-Supervised Development System
**Verzija:** 0.1.0
**Status:** Pokretanje
**Verzija:** 0.3.0
**Status:** Aktivan razvoj
**Autor:** DAL d.o.o.
**Poslednje ažuriranje:** 2026-02-20
**Poslednje azuriranje:** 2026-02-21
---
@ -101,13 +101,23 @@ Deploy ili dorada
│ ├── regulations/
│ └── third-party/
└── TASKS/ ← taskovi, specifikacije, izveštaji
├── MASTER-STATUS.md
├── Architecture.md
├── Workflow-Spec.md
├── Supervisor-Spec.md
├── Multi-Agent-Spec.md
├── Implementation-Tasks.md
├── docs/ ← dokumentacija
│ ├── SPEC.md
│ ├── ARCHITECTURE.md
│ └── SETUP.md
├── code/ ← Go kod (server, testovi)
│ ├── cmd/kaos-server/
│ ├── internal/server/
│ ├── internal/supervisor/
│ └── web/templates/
└── TASKS/ ← taskovi po stanju
├── backlog/
├── ready/
├── active/
├── review/
├── done/
└── reports/
```
@ -115,30 +125,26 @@ Deploy ili dorada
## Verzije
### v0.1 — Osnova (TRENUTNO)
### v0.1 — Osnova
- Mastermind + agenti definisani u CLAUDE.md fajlovima
- Supervisor: ručno pokretanje (`kaos-supervisor run T01`)
- Checker: build + test + vet (deterministički)
- Izveštaji: markdown u TASKS/reports/
- Git: direktno na main
- Nema baze, nema frontend-a, nema AI trijaže
- Supervisor: rucno pokretanje (`kaos-supervisor run T01`)
- Checker: build + test + vet
- Izvestaji: markdown u TASKS/reports/
### v0.2 — Automatizacija (planirano)
- Supervisor daemon ili watch folder
- AI trijaža prijava
- AI compliance provere (modul, pravila, konvencije)
- Staging → main branch strategija
- Auto-retry za flaky testove
- Notifikacije (konfigurabilan kanal)
### v0.2 — Dashboard
- Web dashboard sa Kanban board-om
- Drag & drop premestanje taskova
- SSE real-time update
- Pretraga, dokumenti, prijava taskova
### v0.3 — Kompletni ekosistem (planirano)
- Frontend dashboard
- WebSocket real-time praćenje
- Help sistem
- Embed SDK
- Cost tracking dashboard
- Metrike i analitika
- Distribucija prema licencama
### v0.3 — Konzola i PTY (TRENUTNO)
- xterm.js terminali u browseru
- Svaki task dobija sopstvenu claude PTY sesiju
- "Pusti" automatski pokrece rad
- "Proveri" pokrece review sesiju
- WebSocket za real-time terminal I/O
- Replay buffer za reconnect
- 125+ testova u 12 fajlova
---

View File

@ -1,55 +1,114 @@
# KAOS — Master Status
**Verzija:** 0.3.0
**Poslednje ažuriranje:** 2026-02-20
**Verzija:** v0.3.4
**Poslednje ažuriranje:** 2026-02-20 18:00
---
## Fajl indeks
## Stanje fajlova na Windows-u
| Fajl | Opis |
|------|------|
| `CLAUDE.md` | Mastermind (v0.3.0) |
| `README.md` | Pregled projekta, arhitektura, odluke |
| `agents/*/CLAUDE.md` | 8 agenata (v0.1.0) |
| `TASKS/MASTER-STATUS.md` | Ovo — navigacija, status |
| `TASKS/Implementation-Tasks.md` | Svi taskovi detaljno |
| `TASKS/Workflow-Spec.md` | 10 koraka, odluke |
| `TASKS/Multi-Agent-Spec.md` | Arhitektura agenata |
| Folder | Taskovi |
|--------|---------|
| backlog/ | T17, T18, T23 |
| ready/ | T25 |
| active/ | — |
| review/ | — |
| done/ | (server ima T01-T22, T24) |
---
## Task folderi
## v0.1 Supervisor — ZAVRŠENO ✅ (67 testova)
| Folder | Sadržaj | Taskovi |
|--------|---------|---------|
| backlog/ | Čeka odobrenje | T08, T09, T10 |
| ready/ | Odobren za rad | — |
| active/ | U izradi | — |
| review/ | Čeka pregled/odgovor | — |
| done/ | Završeno | T01, T02, T03, T04, T05, T06, T07 |
| Task | Naslov | Tag |
|------|--------|-----|
| T01 | Inicijalizacija Go projekta | v0.1.1 |
| T02 | Task loader (parsiranje MD) | v0.1.2 |
| T03 | Runner (pokretanje Claude Code) | v0.1.4 |
| T04 | Checker (build + test + vet) | v0.1.3 |
| 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 |
|------|--------|-----|--------|---------|
| T01 | Inicijalizacija Go projekta | v0.1.1 | f001c53 | 6 |
| T02 | Task loader (parsiranje MD) | v0.1.2 | 79bcd52 | 17 |
| T03 | Runner (pokretanje Claude Code) | v0.1.4 | 9d2c249 | 7 |
| T04 | Checker (build + test + vet) | v0.1.3 | 5d869f5 | 10 |
| T05 | Reporter (pisanje izveštaja) | v0.1.5 | 028872b | 10 |
| T06 | CLI (komandni interfejs) | v0.1.6 | 38e1e10 | 9 |
| T07 | Integracija (end-to-end) | v0.1.7 | b2ece98 | 8 |
| **Ukupno** | | | | **67** |
| # | Šta | Prioritet |
|---|-----|-----------|
| T26 | Tema: svetla/tamna/auto | nizak |
| T27 | Fix: konzola se prazni posle vremena | srednji |
| T28 | Notifikacije (zvuk/badge kad task završi) | srednji |
| T29 | Task editor iz dashboarda | nizak |
| T30 | Autentifikacija (login) | visok (pre produkcije) |
| T31 | Deployer agent implementacija | visok |
| T32 | Cost tracking (tokeni, cena, vreme po tasku) | srednji |
---
## Sledeće — v0.2 Dashboard
## Poznati bugovi
| Task | Naslov | Folder | Zavisi od |
|------|--------|--------|-----------|
| T08 | HTTP server + API | backlog | T07 ✅ |
| T09 | Dashboard kanban board | backlog | T08 |
| T10 | Drag & Drop | backlog | T09 |
| Bug | Task | Status |
|-----|------|--------|
| Konzola se prazni posle vremena | T27 | za kreiranje |
| Sync vraća obrisane fajlove iz done/ | — | otvoreno |
| "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/` |

View 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

View 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 |

53
TASKS/review/T26.md Normal file
View 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
View 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

View File

@ -2,3 +2,4 @@ KAOS_TIMEOUT=30m
KAOS_PROJECT_PATH=.
KAOS_TASKS_DIR=../TASKS
KAOS_PORT=8080
KAOS_LOG_FILE=/tmp/kaos-server.log

View File

@ -8,6 +8,7 @@ require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // 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/gin-contrib/sse v1.1.0 // 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/goccy/go-json v0.10.2 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

View File

@ -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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=

View File

@ -20,6 +20,8 @@ type Config struct {
TasksDir string
// Port is the HTTP server port.
Port string
// LogFile is the path to the server log file (optional).
LogFile string
}
// Load reads configuration from environment variables.
@ -53,11 +55,14 @@ func Load() (*Config, error) {
port = "8080"
}
logFile := os.Getenv("KAOS_LOG_FILE")
return &Config{
Timeout: timeout,
ProjectPath: projectPath,
TasksDir: tasksDir,
Port: port,
LogFile: logFile,
}, nil
}

View 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")
}
}

View File

@ -1,404 +1,168 @@
package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"strconv"
"os"
"path/filepath"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// sessionState represents the state of a console session.
type sessionState struct {
mu sync.Mutex
status string // "idle" or "running"
cmd *exec.Cmd
execID string
taskID string // which task is being worked on (if any)
history []historyEntry
output []string
listeners map[chan string]bool
// 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
}
// historyEntry represents a command in the session history.
type historyEntry struct {
Command string `json:"command"`
ExecID string `json:"exec_id"`
Timestamp string `json:"timestamp"`
Status string `json:"status"` // "running", "done", "error", "killed"
// 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"`
}
// execRequest is the JSON body for starting a command.
type execRequest struct {
Cmd string `json:"cmd"`
Session int `json:"session"`
// taskSessionManager manages dynamic PTY sessions per task.
type taskSessionManager struct {
mu sync.RWMutex
sessions map[string]*taskSession
}
// execResponse is the JSON response after starting a command.
type execResponse struct {
ExecID string `json:"exec_id"`
Session int `json:"session"`
}
// sessionStatus represents the status of a session for the API.
type sessionStatus struct {
Session int `json:"session"`
Status string `json:"status"`
TaskID string `json:"task_id,omitempty"`
ExecID string `json:"exec_id,omitempty"`
}
// consoleManager manages the two console sessions.
type consoleManager struct {
sessions [2]*sessionState
mu sync.Mutex
counter int
}
// newConsoleManager creates a new console manager with two idle sessions.
func newConsoleManager() *consoleManager {
return &consoleManager{
sessions: [2]*sessionState{
{status: "idle", listeners: make(map[chan string]bool)},
{status: "idle", listeners: make(map[chan string]bool)},
},
func newTaskSessionManager() *taskSessionManager {
return &taskSessionManager{
sessions: make(map[string]*taskSession),
}
}
// nextExecID generates a unique execution ID.
func (cm *consoleManager) nextExecID() string {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.counter++
return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter)
// sessionKey returns a unique key for a task session.
func sessionKey(taskID, sessionType string) string {
if sessionType == "review" {
return taskID + "-review"
}
return taskID
}
// getSession returns a session by index (0 or 1).
func (cm *consoleManager) getSession(idx int) *sessionState {
if idx < 0 || idx > 1 {
return nil
// 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)
}
return cm.sessions[idx]
}
sm.mu.Unlock()
// handleConsoleExec starts a command in a session.
func (s *Server) handleConsoleExec(c *gin.Context) {
var req execRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidan JSON: " + err.Error()})
return
}
if req.Session < 1 || req.Session > 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"})
return
}
sessionIdx := req.Session - 1
session := s.console.getSession(sessionIdx)
session.mu.Lock()
if session.status == "running" {
session.mu.Unlock()
c.JSON(http.StatusConflict, gin.H{"error": "sesija je zauzeta"})
return
}
execID := s.console.nextExecID()
session.status = "running"
session.execID = execID
session.output = nil
session.mu.Unlock()
// Add to history
entry := historyEntry{
Command: req.Cmd,
ExecID: execID,
Timestamp: timeNow(),
Status: "running",
}
session.mu.Lock()
session.history = append(session.history, entry)
if len(session.history) > 50 {
session.history = session.history[len(session.history)-50:]
}
session.mu.Unlock()
// Start the command in background
go s.runCommand(session, req.Cmd, execID)
c.JSON(http.StatusOK, execResponse{
ExecID: execID,
Session: req.Session,
})
}
// runCommand executes a command and streams output to listeners.
func (s *Server) runCommand(session *sessionState, command, execID string) {
// Build the claude command
cmd := exec.Command("claude", "--dangerously-skip-permissions", "-p", command)
cmd.Dir = s.projectRoot()
stdout, err := cmd.StdoutPipe()
ptySess, err := spawnTaskPTY(projectDir)
if err != nil {
s.sendToSession(session, "[greška: "+err.Error()+"]")
s.finishSession(session, execID, "error")
return
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
s.sendToSession(session, "[greška: "+err.Error()+"]")
s.finishSession(session, execID, "error")
return
sess := &taskSession{
TaskID: taskID,
Type: sessionType,
PTY: ptySess,
Started: time.Now(),
}
session.mu.Lock()
session.cmd = cmd
session.mu.Unlock()
sm.mu.Lock()
sm.sessions[key] = sess
sm.mu.Unlock()
if err := cmd.Start(); err != nil {
s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]")
s.finishSession(session, execID, "error")
return
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)
}
// Read stdout and stderr concurrently
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
s.streamReader(session, stdout)
}()
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
ch := ptySess.Subscribe(subID)
go func() {
defer wg.Done()
s.streamReader(session, stderr)
}()
wg.Wait()
err = cmd.Wait()
status := "done"
if err != nil {
if _, ok := err.(*exec.ExitError); ok {
status = "error"
}
}
s.finishSession(session, execID, status)
}
// streamReader reads from a reader line by line and sends to session.
func (s *Server) streamReader(session *sessionState, reader io.Reader) {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 64*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
s.sendToSession(session, line)
}
}
// sendToSession sends a line to all listeners and stores in output buffer.
func (s *Server) sendToSession(session *sessionState, line string) {
session.mu.Lock()
defer session.mu.Unlock()
session.output = append(session.output, line)
for ch := range session.listeners {
timer := time.NewTimer(30 * time.Second)
select {
case ch <- line:
default:
// Skip if channel is full
}
}
}
// finishSession marks a session as idle and notifies listeners.
func (s *Server) finishSession(session *sessionState, execID, status string) {
session.mu.Lock()
defer session.mu.Unlock()
session.status = "idle"
session.cmd = nil
// Update history entry status
for i := len(session.history) - 1; i >= 0; i-- {
if session.history[i].ExecID == execID {
session.history[i].Status = status
break
}
}
// Notify listeners that stream is done
for ch := range session.listeners {
select {
case ch <- "[DONE]":
default:
}
}
}
// handleConsoleStream serves an SSE stream for a command execution.
func (s *Server) handleConsoleStream(c *gin.Context) {
execID := c.Param("id")
// Find which session has this exec ID
var session *sessionState
for i := 0; i < 2; i++ {
sess := s.console.getSession(i)
sess.mu.Lock()
if sess.execID == execID {
session = sess
sess.mu.Unlock()
break
}
sess.mu.Unlock()
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
return
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
// Create listener channel
ch := make(chan string, 100)
session.mu.Lock()
// Send buffered output first
for _, line := range session.output {
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
}
c.Writer.Flush()
// If already done, send done event and return
if session.status == "idle" && session.execID == execID {
session.mu.Unlock()
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
c.Writer.Flush()
return
}
session.listeners[ch] = true
session.mu.Unlock()
// Clean up on disconnect
defer func() {
session.mu.Lock()
delete(session.listeners, ch)
session.mu.Unlock()
}()
notify := c.Request.Context().Done()
for {
select {
case <-notify:
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
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()
}
}
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
}
// handleConsoleKill kills the running process in a session.
func (s *Server) handleConsoleKill(c *gin.Context) {
sessionNum, err := strconv.Atoi(c.Param("session"))
if err != nil || sessionNum < 1 || sessionNum > 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"})
return
}
// 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]
}
session := s.console.getSession(sessionNum - 1)
// listSessions returns all active sessions.
func (sm *taskSessionManager) listSessions() []taskSessionResponse {
sm.mu.RLock()
defer sm.mu.RUnlock()
session.mu.Lock()
defer session.mu.Unlock()
if session.status != "running" || session.cmd == nil {
c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"})
return
}
if session.cmd.Process != nil {
session.cmd.Process.Kill()
}
// Update history
for i := len(session.history) - 1; i >= 0; i-- {
if session.history[i].ExecID == session.execID {
session.history[i].Status = "killed"
break
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"),
})
}
session.status = "idle"
session.cmd = nil
c.JSON(http.StatusOK, gin.H{"status": "killed"})
return result
}
// handleConsoleSessions returns the status of both sessions.
func (s *Server) handleConsoleSessions(c *gin.Context) {
statuses := make([]sessionStatus, 2)
// killSession terminates a session and removes it.
func (sm *taskSessionManager) killSession(taskID, sessionType string) bool {
key := sessionKey(taskID, sessionType)
for i := 0; i < 2; i++ {
sess := s.console.getSession(i)
sess.mu.Lock()
statuses[i] = sessionStatus{
Session: i + 1,
Status: sess.status,
TaskID: sess.taskID,
ExecID: sess.execID,
}
sess.mu.Unlock()
sm.mu.Lock()
sess, exists := sm.sessions[key]
if exists {
delete(sm.sessions, key)
}
sm.mu.Unlock()
if !exists {
return false
}
c.JSON(http.StatusOK, statuses)
}
// handleConsoleHistory returns command history for a session.
func (s *Server) handleConsoleHistory(c *gin.Context) {
sessionNum, err := strconv.Atoi(c.Param("session"))
if err != nil || sessionNum < 1 || sessionNum > 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"})
return
}
session := s.console.getSession(sessionNum - 1)
session.mu.Lock()
history := make([]historyEntry, len(session.history))
copy(history, session.history)
session.mu.Unlock()
data, _ := json.Marshal(history)
c.Header("Content-Type", "application/json")
c.String(http.StatusOK, string(data))
}
// timeNow returns the current time formatted as HH:MM:SS.
func timeNow() string {
return time.Now().Format("15:04:05")
log.Printf("Session[%s]: killed", key)
sess.PTY.Close()
return true
}
// handleConsolePage serves the console HTML page.
@ -406,3 +170,106 @@ 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")
}

View 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")
}
}

View 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")
}
}

View 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)
}
}

View 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:]
}

View 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])
}
}

View 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
}
}
}

View 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
}

View File

@ -33,7 +33,7 @@ type dashboardData struct {
// taskDetailData holds data for the task detail panel.
type taskDetailData struct {
Task supervisor.Task
Content string
Content template.HTML
HasReport bool
}
@ -68,6 +68,7 @@ func init() {
"templates/docs-list.html",
"templates/docs-view.html",
"templates/console.html",
"templates/submit.html",
"templates/partials/column.html",
"templates/partials/task-card.html",
"templates/partials/task-detail.html",
@ -184,6 +185,15 @@ func renderConsolePage() string {
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
@ -193,11 +203,23 @@ func renderSearchResults(data searchResultsData) string {
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.
// Content is rendered from markdown to HTML using goldmark.
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
rendered := renderMarkdown([]byte(content), t.ID+".md")
data := taskDetailData{
Task: t,
Content: content,
Content: template.HTML(rendered),
HasReport: hasReport,
}

View 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")
}
}

View File

@ -2,11 +2,15 @@
package server
import (
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@ -19,8 +23,10 @@ import (
type Server struct {
Config *config.Config
Router *gin.Engine
console *consoleManager
console *taskSessionManager
events *eventBroker
chatMu sync.RWMutex
chats map[string]*chatState
}
// taskResponse is the JSON representation of a task.
@ -68,8 +74,9 @@ func New(cfg *config.Config) *Server {
s := &Server{
Config: cfg,
Router: router,
console: newConsoleManager(),
console: newTaskSessionManager(),
events: newEventBroker(cfg.TasksDir),
chats: make(map[string]*chatState),
}
// No caching for dynamic routes — disk is the source of truth.
@ -100,6 +107,7 @@ func (s *Server) setupRoutes() {
s.Router.GET("/task/:id", s.handleTaskDetail)
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)
// SSE events
@ -110,15 +118,22 @@ func (s *Server) setupRoutes() {
// Console routes
s.Router.GET("/console", s.handleConsolePage)
s.Router.POST("/console/exec", s.handleConsoleExec)
s.Router.GET("/console/stream/:id", s.handleConsoleStream)
s.Router.POST("/console/kill/:session", s.handleConsoleKill)
s.Router.GET("/console/sessions", s.handleConsoleSessions)
s.Router.GET("/console/history/:session", s.handleConsoleHistory)
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.
@ -197,6 +212,12 @@ func (s *Server) apiMoveTask(c *gin.Context) {
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})
}
@ -267,6 +288,12 @@ func (s *Server) handleMoveTask(c *gin.Context) {
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
s.handleDashboard(c)
}
@ -316,69 +343,72 @@ func (s *Server) handleRunTask(c *gin.Context) {
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"
}
// Find free session
sessionIdx := -1
for i := 0; i < 2; i++ {
sess := s.console.getSession(i)
sess.mu.Lock()
if sess.status == "idle" {
sessionIdx = i
sess.mu.Unlock()
break
}
sess.mu.Unlock()
}
if sessionIdx == -1 {
c.JSON(http.StatusConflict, gin.H{"error": "obe sesije su zauzete"})
// 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
}
// Build the prompt
prompt := "Pročitaj CLAUDE.md u root-u projekta. Tvoj task: TASKS/ready/" + id + ".md — Pročitaj task fajl i uradi šta piše. Prati pravila iz CLAUDE.md."
// Append "Pokrenut" timestamp
taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md")
appendTimestamp(taskPath, "Pokrenut (→active)")
// Start in the session
session := s.console.getSession(sessionIdx)
execID := s.console.nextExecID()
// Read task content and spawn a claude work session
taskContent, _ := os.ReadFile(taskPath)
prompt := buildWorkPrompt(id, taskContent)
session.mu.Lock()
session.status = "running"
session.execID = execID
session.taskID = id
session.output = nil
session.history = append(session.history, historyEntry{
Command: "pusti " + id,
ExecID: execID,
Timestamp: timeNow(),
Status: "running",
})
session.mu.Unlock()
go s.runCommand(session, prompt, execID)
_, 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",
"session": sessionIdx + 1,
"exec_id": execID,
"task": id,
"session": id,
})
}
// handleReport serves a task report file.
// 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) {
id := strings.ToUpper(c.Param("id"))
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
content, err := os.ReadFile(reportPath)
if err != nil {
c.String(http.StatusNotFound, "Izveštaj za %s nije pronađen", id)
return
var content []byte
var title string
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.String(http.StatusOK, string(content))
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderReportModal(id, title, string(content)))
}
// Run starts the HTTP server.
@ -429,6 +459,40 @@ func isMoveAllowed(from, to string) bool {
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 {
deps := t.DependsOn
if deps == nil {

File diff suppressed because it is too large Load Diff

View 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
}
}

View 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()
}

View 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")
}
}

View 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)
}
}

View 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
}

View 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))
}
}

View 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
View 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)
}

View File

@ -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; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
background: var(--bg-page);
color: var(--text);
}
/* === Header === */
.header {
padding: 16px 24px;
background: #16213e;
background: var(--bg-panel);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #0f3460;
border-bottom: 2px solid var(--border);
}
.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 {
display: grid;
grid-template-columns: repeat(5, 1fr);
@ -27,7 +119,7 @@ body {
}
.column {
background: #16213e;
background: var(--bg-panel);
border-radius: 8px;
padding: 12px;
min-height: 200px;
@ -37,21 +129,22 @@ body {
font-weight: bold;
padding: 8px;
margin-bottom: 8px;
border-bottom: 2px solid #0f3460;
border-bottom: 2px solid var(--border);
display: flex;
justify-content: space-between;
}
.column-count {
background: #0f3460;
background: var(--bg-hover);
border-radius: 12px;
padding: 2px 8px;
font-size: 0.85em;
}
/* === Task cards === */
.task-card {
background: #1a1a2e;
border: 1px solid #333;
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
@ -60,13 +153,13 @@ body {
}
.task-card:hover {
border-color: #e94560;
border-color: var(--accent);
transform: translateY(-1px);
}
.task-id {
font-weight: bold;
color: #e94560;
color: var(--accent);
}
.task-title {
@ -77,54 +170,59 @@ body {
.task-meta {
margin-top: 6px;
font-size: 0.75em;
color: #888;
color: var(--text-muted);
}
.task-deps {
font-size: 0.75em;
color: #666;
color: var(--text-dim);
margin-top: 4px;
}
/* Task detail panel — 50% of screen */
/* === Task detail modal === */
#task-detail {
display: none;
position: fixed;
top: 0;
right: -50%;
width: 50%;
left: 0;
width: 100%;
height: 100vh;
background: #16213e;
border-left: 2px solid #0f3460;
padding: 20px;
overflow-y: auto;
z-index: 10;
transition: right 0.3s ease;
z-index: 50;
background: var(--overlay);
justify-content: center;
align-items: center;
}
#task-detail.active {
right: 0;
display: flex;
}
/* Board compresses when detail is open */
.board {
transition: margin-right 0.3s ease;
}
body.detail-open .board {
margin-right: 50%;
.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 {
cursor: pointer;
float: right;
font-size: 1.2em;
color: #888;
position: absolute;
top: 12px;
right: 16px;
font-size: 1.4em;
color: var(--text-muted);
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-actions {
@ -134,23 +232,33 @@ body.detail-open .board {
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 {
display: inline-block;
padding: 8px 16px;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a2e;
color: #eee;
border: 1px solid var(--border-light);
background: var(--bg-page);
color: var(--text);
cursor: pointer;
font-size: 0.85em;
text-decoration: none;
transition: background 0.2s;
}
.btn:hover { background: #0f3460; }
.btn:hover { background: var(--bg-hover); }
.btn-move { border-color: var(--accent); }
.btn-move { border-color: #e94560; }
/* Task action buttons */
.task-action {
margin-top: 6px;
text-align: right;
@ -161,27 +269,27 @@ body.detail-open .board {
padding: 4px 10px;
}
.btn-run { border-color: #4ecca3; color: #4ecca3; }
.btn-run:hover { background: #4ecca3; color: #1a1a2e; }
.btn-run { border-color: var(--success); color: var(--success); }
.btn-run:hover { background: var(--success); color: var(--text-on-color); }
.btn-review { border-color: #6ec6ff; color: #6ec6ff; }
.btn-review:hover { background: #6ec6ff; color: #1a1a2e; }
.btn-review { border-color: var(--info); color: var(--info); }
.btn-review:hover { background: var(--info); color: var(--text-on-color); }
.btn-approve { border-color: #ffd93d; color: #ffd93d; }
.btn-approve:hover { background: #ffd93d; color: #1a1a2e; }
.btn-approve { border-color: var(--warning); color: var(--warning); }
.btn-approve:hover { background: var(--warning); color: var(--text-on-color); }
.btn-report { border-color: #888; color: #888; }
.btn-report:hover { background: #888; color: #1a1a2e; }
.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: #444;
color: #555;
border-color: var(--border-disabled);
color: var(--text-disabled);
cursor: default;
}
.btn-running {
border-color: #e94560;
color: #e94560;
border-color: var(--accent);
color: var(--accent);
cursor: default;
animation: pulse 1.5s ease infinite;
}
@ -190,36 +298,28 @@ body.detail-open .board {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.btn-success { border-color: #4ecca3; color: #4ecca3; }
.btn-success:hover { background: #4ecca3; color: #1a1a2e; }
.detail-content {
white-space: pre-wrap;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8em;
margin-top: 16px;
line-height: 1.6;
padding: 12px;
background: #111;
border-radius: 6px;
max-height: 60vh;
overflow-y: auto;
.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 column-tasks container */
/* === Sortable / Drag & Drop === */
.column-tasks {
min-height: 50px;
}
/* Drag & Drop styles */
.task-ghost {
opacity: 0.4;
border: 2px dashed #e94560;
background: #0f3460;
border: 2px dashed var(--accent);
background: var(--bg-hover);
}
.task-chosen {
box-shadow: 0 4px 16px rgba(233, 69, 96, 0.3);
box-shadow: 0 4px 16px var(--accent-shadow);
}
.task-drag {
@ -227,27 +327,26 @@ body.detail-open .board {
transform: rotate(2deg);
}
/* Drop zone highlight */
.column-tasks.sortable-drag-over {
background: rgba(15, 52, 96, 0.3);
background: var(--drag-over);
border-radius: 6px;
}
/* Flash animations */
/* === Flash animations === */
@keyframes flash-success {
0% { background: #4ecca3; }
100% { background: #1a1a2e; }
0% { background: var(--success); }
100% { background: var(--bg-page); }
}
@keyframes flash-error {
0% { background: #e94560; }
100% { background: #1a1a2e; }
0% { background: var(--accent); }
100% { background: var(--bg-page); }
}
.flash-success { animation: flash-success 0.5s ease; }
.flash-error { animation: flash-error 0.5s ease; }
/* Toast notifications */
/* === Toast === */
.toast {
position: fixed;
bottom: 20px;
@ -266,32 +365,25 @@ body.detail-open .board {
}
.toast-success {
background: #4ecca3;
color: #1a1a2e;
background: var(--success);
color: var(--text-on-color);
}
.toast-error {
background: #e94560;
background: var(--accent);
color: #fff;
}
/* Header right section */
.header-right {
display: flex;
gap: 12px;
align-items: center;
}
/* Search */
/* === Search === */
.search-wrapper {
position: relative;
}
.search-wrapper input {
background: #1a1a2e;
border: 1px solid #333;
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 6px;
color: #eee;
color: var(--text);
padding: 6px 12px;
font-size: 0.85em;
width: 220px;
@ -300,7 +392,7 @@ body.detail-open .board {
}
.search-wrapper input:focus {
border-color: #e94560;
border-color: var(--accent);
width: 300px;
}
@ -311,9 +403,9 @@ body.detail-open .board {
width: 400px;
max-height: 500px;
overflow-y: auto;
background: #16213e;
background: var(--bg-panel);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
box-shadow: 0 8px 24px var(--shadow);
z-index: 50;
margin-top: 4px;
}
@ -326,8 +418,8 @@ body.detail-open .board {
display: block;
padding: 10px 14px;
text-decoration: none;
color: #eee;
border-bottom: 1px solid #0f3460;
color: var(--text);
border-bottom: 1px solid var(--border);
transition: background 0.15s;
}
@ -336,7 +428,7 @@ body.detail-open .board {
}
.search-result:hover {
background: #0f3460;
background: var(--bg-hover);
}
.search-result-header {
@ -356,19 +448,19 @@ body.detail-open .board {
font-size: 0.7em;
padding: 2px 6px;
border-radius: 4px;
background: #0f3460;
background: var(--bg-hover);
margin-left: auto;
}
.search-status-done { color: #4ecca3; }
.search-status-active { color: #e94560; }
.search-status-review { color: #ffd93d; }
.search-status-ready { color: #6ec6ff; }
.search-status-backlog { color: #888; }
.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: #888;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.4;
overflow: hidden;
@ -379,23 +471,18 @@ body.detail-open .board {
.search-empty {
padding: 16px;
text-align: center;
color: #888;
color: var(--text-muted);
font-size: 0.85em;
}
/* Navigation */
/* === Navigation === */
.nav {
display: flex;
gap: 8px;
align-items: center;
}
.btn-active {
background: #0f3460;
border-color: #e94560;
}
/* Docs — full height */
/* === Docs === */
.docs-container {
padding: 16px 24px;
height: calc(100vh - 60px);
@ -413,7 +500,7 @@ body.detail-open .board {
.docs-sidebar h2 {
margin-bottom: 12px;
color: #e94560;
color: var(--accent);
font-size: 1.1em;
}
@ -428,9 +515,9 @@ body.detail-open .board {
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #16213e;
background: var(--bg-panel);
border-radius: 6px;
color: #eee;
color: var(--text);
text-decoration: none;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
@ -438,7 +525,7 @@ body.detail-open .board {
}
.doc-item:hover {
background: #0f3460;
background: var(--bg-hover);
}
.doc-icon { font-size: 1em; }
@ -449,7 +536,7 @@ body.detail-open .board {
}
.docs-breadcrumbs a {
color: #e94560;
color: var(--accent);
text-decoration: none;
}
@ -458,7 +545,7 @@ body.detail-open .board {
}
.breadcrumb-sep {
color: #555;
color: var(--text-dim);
margin: 0 4px;
}
@ -472,7 +559,7 @@ body.detail-open .board {
}
.docs-content {
background: #16213e;
background: var(--bg-panel);
border-radius: 8px;
padding: 24px;
line-height: 1.7;
@ -480,24 +567,24 @@ body.detail-open .board {
}
.docs-content h1, .docs-content h2, .docs-content h3 {
color: #e94560;
color: var(--accent);
margin-top: 1.2em;
margin-bottom: 0.5em;
}
.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid #333; padding-bottom: 8px; }
.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: #4ecca3;
color: var(--success);
text-decoration: none;
}
.docs-content a:hover { text-decoration: underline; }
.docs-content code {
background: #1a1a2e;
background: var(--bg-page);
padding: 2px 6px;
border-radius: 3px;
font-family: "JetBrains Mono", "Fira Code", monospace;
@ -505,7 +592,7 @@ body.detail-open .board {
}
.docs-content pre {
background: #1a1a2e;
background: var(--bg-page);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
@ -524,13 +611,13 @@ body.detail-open .board {
}
.docs-content th, .docs-content td {
border: 1px solid #333;
border: 1px solid var(--border-light);
padding: 8px 12px;
text-align: left;
}
.docs-content th {
background: #0f3460;
background: var(--bg-hover);
}
.docs-content ul, .docs-content ol {
@ -541,19 +628,19 @@ body.detail-open .board {
.docs-content li { margin: 4px 0; }
.docs-content blockquote {
border-left: 3px solid #e94560;
border-left: 3px solid var(--accent);
padding-left: 12px;
color: #aaa;
color: var(--text-secondary);
margin: 12px 0;
}
.docs-content hr {
border: none;
border-top: 1px solid #333;
border-top: 1px solid var(--border-light);
margin: 16px 0;
}
/* Console — fullscreen */
/* === Console === */
.console-container {
padding: 16px;
height: calc(100vh - 60px);
@ -573,7 +660,7 @@ body.detail-open .board {
flex: 1;
display: flex;
flex-direction: column;
background: #16213e;
background: var(--bg-panel);
border-radius: 8px;
overflow: hidden;
}
@ -583,7 +670,7 @@ body.detail-open .board {
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #0f3460;
border-bottom: 1px solid var(--border);
font-size: 0.9em;
}
@ -591,66 +678,55 @@ body.detail-open .board {
font-size: 0.75em;
padding: 2px 8px;
border-radius: 4px;
background: #0f3460;
background: var(--bg-hover);
}
.session-idle { color: #888; }
.session-running { color: #4ecca3; }
.session-idle { color: var(--text-muted); }
.session-running { color: var(--success); }
.btn-kill {
margin-left: auto;
border-color: #e94560;
color: #e94560;
border-color: var(--accent);
color: var(--accent);
padding: 4px 10px;
font-size: 0.75em;
}
.console-output {
.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;
padding: 12px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8em;
line-height: 1.5;
background: #111;
}
.console-cmd {
color: #4ecca3;
font-weight: bold;
margin-top: 8px;
}
.console-line {
color: #ddd;
white-space: pre-wrap;
word-break: break-all;
}
.console-error {
color: #e94560;
}
.console-done {
color: #666;
margin-top: 4px;
font-style: italic;
}
.console-input-row {
display: flex;
gap: 4px;
padding: 8px;
border-top: 1px solid #0f3460;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.console-input {
flex: 1;
background: #1a1a2e;
border: 1px solid #333;
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 6px;
color: #eee;
color: var(--text);
padding: 8px 12px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.85em;
@ -658,7 +734,7 @@ body.detail-open .board {
}
.console-input:focus {
border-color: #e94560;
border-color: var(--accent);
}
.console-input:disabled {
@ -672,15 +748,161 @@ body.detail-open .board {
flex-shrink: 0;
}
/* Responsive */
/* === 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) {
.board { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.board { grid-template-columns: repeat(2, 1fr); }
#task-detail { width: 100%; right: -100%; }
body.detail-open .board { margin-right: 0; }
.docs-layout { grid-template-columns: 1fr; }
}

View File

@ -5,173 +5,259 @@
<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>
<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">
<button class="btn" id="toggle-panel" onclick="togglePanel2()">+ Sesija 2</button>
<span id="session-info">Sesije: 0</span>
</div>
<div class="console-panels">
<div class="console-panel" id="panel-1">
<div class="console-panel-header">
<span>🔧 Sesija 1</span>
<span class="session-status" id="status-1">idle</span>
<button class="btn btn-kill" id="kill-1" onclick="killSession(1)" style="display:none">Prekini</button>
</div>
<div class="console-output" id="output-1"></div>
<div class="console-input-row">
<input type="text" id="input-1" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 1)" autocomplete="off">
<button class="btn btn-move" onclick="sendCommand(1)"></button>
</div>
</div>
<div class="console-panel" id="panel-2" style="display:none">
<div class="console-panel-header">
<span>🔧 Sesija 2</span>
<span class="session-status" id="status-2">idle</span>
<button class="btn btn-kill" id="kill-2" onclick="killSession(2)" style="display:none">Prekini</button>
</div>
<div class="console-output" id="output-2"></div>
<div class="console-input-row">
<input type="text" id="input-2" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 2)" autocomplete="off">
<button class="btn btn-move" onclick="sendCommand(2)"></button>
</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>
var historyIdx = [0, 0];
var cmdHistory = [[], []];
function handleKey(e, session) {
if (e.key === 'Enter') {
sendCommand(session);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
var idx = session - 1;
if (historyIdx[idx] > 0) {
historyIdx[idx]--;
document.getElementById('input-' + session).value = cmdHistory[idx][historyIdx[idx]] || '';
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
var idx = session - 1;
if (historyIdx[idx] < cmdHistory[idx].length) {
historyIdx[idx]++;
document.getElementById('input-' + session).value = cmdHistory[idx][historyIdx[idx]] || '';
}
// ── 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;
}
function sendCommand(session) {
var input = document.getElementById('input-' + session);
var cmd = input.value.trim();
if (!cmd) return;
// ── Session state ────────────────────────────────────
var terminals = {};
var idx = session - 1;
cmdHistory[idx].push(cmd);
historyIdx[idx] = cmdHistory[idx].length;
function sessionKey(sess) {
return sess.type === 'review' ? sess.task_id + '-review' : sess.task_id;
}
var output = document.getElementById('output-' + session);
output.innerHTML += '<div class="console-cmd">&gt; ' + escapeHtml(cmd) + '</div>';
input.value = '';
function createTerminal(sess) {
var key = sessionKey(sess);
if (terminals[key]) return;
setSessionUI(session, 'running');
document.getElementById('empty-state').style.display = 'none';
fetch('/console/exec', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({cmd: cmd, session: session})
})
.then(function(resp) {
if (!resp.ok) {
return resp.json().then(function(data) {
output.innerHTML += '<div class="console-error">' + escapeHtml(data.error) + '</div>';
setSessionUI(session, 'idle');
throw new Error(data.error);
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();
}
return resp.json();
})
.then(function(data) {
if (!data) return;
streamOutput(session, data.exec_id);
})
.catch(function(err) {
setSessionUI(session, 'idle');
});
}
});
function streamOutput(session, execId) {
var output = document.getElementById('output-' + session);
var source = new EventSource('/console/stream/' + execId);
source.onmessage = function(e) {
output.innerHTML += '<div class="console-line">' + escapeHtml(e.data) + '</div>';
output.scrollTop = output.scrollHeight;
};
source.addEventListener('done', function(e) {
source.close();
output.innerHTML += '<div class="console-done">--- gotovo ---</div>';
output.scrollTop = output.scrollHeight;
setSessionUI(session, 'idle');
});
source.onerror = function() {
source.close();
setSessionUI(session, 'idle');
};
}
function killSession(session) {
fetch('/console/kill/' + session, {method: 'POST'})
.then(function() {
var output = document.getElementById('output-' + session);
output.innerHTML += '<div class="console-error">--- prekinuto ---</div>';
setSessionUI(session, 'idle');
});
}
function setSessionUI(session, status) {
document.getElementById('status-' + session).textContent = status;
document.getElementById('status-' + session).className = 'session-status session-' + status;
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none';
document.getElementById('input-' + session).disabled = status === 'running';
}
function togglePanel2() {
var panel = document.getElementById('panel-2');
var btn = document.getElementById('toggle-panel');
if (panel.style.display === 'none') {
panel.style.display = 'flex';
btn.textContent = '- Sesija 2';
} else {
panel.style.display = 'none';
btn.textContent = '+ Sesija 2';
}
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ── Initialize ───────────────────────────────────────
refreshSessions();
setInterval(refreshSessions, 5000);
</script>
</body>
</html>

View File

@ -15,6 +15,7 @@
<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">

View File

@ -15,6 +15,7 @@
<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">

View File

@ -4,9 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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">
<script src="/static/htmx.min.js"></script>
<script src="/static/sortable.min.js"></script>
<script src="/static/theme.js"></script>
</head>
<body>
<div class="header">
@ -22,10 +24,17 @@
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>
@ -36,7 +45,6 @@
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.detail.target.id === 'task-detail') {
e.detail.target.classList.add('active');
document.body.classList.add('detail-open');
}
});
@ -44,7 +52,39 @@ function closeDetail() {
var el = document.getElementById('task-detail');
el.classList.remove('active');
el.innerHTML = '';
document.body.classList.remove('detail-open');
}
// 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()">&times;</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) {
@ -56,16 +96,39 @@ function showToast(msg, type) {
}, 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(data.exec_id ? 'Pokrenuto u sesiji ' + data.session : 'Pokrenuto', 'success');
showToast('Pokrenuto: ' + (data.task || ''), 'success');
setTimeout(function() { window.location.href = '/console'; }, 800);
} else {
var data = JSON.parse(xhr.responseText);
showToast(data.error || 'Greška', 'error');
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'});
}
@ -84,12 +147,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (wrapper && !wrapper.contains(e.target)) {
results.innerHTML = '';
}
// Close detail panel on click outside
var detail = document.getElementById('task-detail');
if (detail && detail.classList.contains('active') && !detail.contains(e.target) && !e.target.closest('.task-card')) {
closeDetail();
}
});
});

View File

@ -18,7 +18,7 @@
{{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()">Izvestaj</button>
<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>

View File

@ -1,28 +1,31 @@
{{define "task-detail"}}
<span class="detail-close" onclick="closeDetail()"></span>
<h2>{{.Task.ID}}: {{.Task.Title}}</h2>
<div class="detail-meta">
<p><strong>Agent:</strong> {{.Task.Agent}} · <strong>Model:</strong> {{.Task.Model}} · <strong>Status:</strong> {{.Task.Status}}</p>
{{if .Task.DependsOn}}
<p><strong>Zavisi od:</strong> {{joinDeps .Task.DependsOn}}</p>
<div class="detail-inner">
<span class="detail-close" onclick="closeDetail()"></span>
<h2>{{.Task.ID}}: {{.Task.Title}}</h2>
<div class="detail-meta">
<p><strong>Agent:</strong> {{.Task.Agent}} · <strong>Model:</strong> {{.Task.Model}} · <strong>Status:</strong> {{.Task.Status}}</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}}
</div>
{{if .HasReport}}
<div class="detail-actions">
<a href="/report/{{.Task.ID}}" class="btn" target="_blank">Izvestaj</a>
<div class="detail-actions">
{{if eq .Task.Status "backlog"}}
<button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button>
{{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>
{{end}}
<div class="detail-actions">
{{if eq .Task.Status "backlog"}}
<button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button>
{{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-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">{{.Content}}</div>
{{end}}

View 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
View 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
View 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
View 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 |