From 397016ad68e7ca213e36ea720c6535ac44a71c01 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 20 Jul 2026 16:00:41 +0100 Subject: [PATCH] feat: Updated workflow runs page --- .../plans/2026-07-17-web-console.md | 1429 ------------- .../plans/2026-07-20-server-workflows.md | 1823 ----------------- .../plans/2026-07-20-workflow-builder-v2.md | 747 ------- .../2026-07-20-workflow-log-streaming.md | 887 -------- .../specs/2026-07-17-web-console-design.md | 241 --- .../2026-07-20-server-workflows-design.md | 238 --- .../2026-07-20-workflow-builder-v2-design.md | 150 -- ...026-07-20-workflow-log-streaming-design.md | 193 -- web/app/globals.css | 19 + web/app/workflows/[id]/runs/[runId]/page.tsx | 513 ++++- web/tsconfig.tsbuildinfo | 2 +- 11 files changed, 456 insertions(+), 5786 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-17-web-console.md delete mode 100644 docs/superpowers/plans/2026-07-20-server-workflows.md delete mode 100644 docs/superpowers/plans/2026-07-20-workflow-builder-v2.md delete mode 100644 docs/superpowers/plans/2026-07-20-workflow-log-streaming.md delete mode 100644 docs/superpowers/specs/2026-07-17-web-console-design.md delete mode 100644 docs/superpowers/specs/2026-07-20-server-workflows-design.md delete mode 100644 docs/superpowers/specs/2026-07-20-workflow-builder-v2-design.md delete mode 100644 docs/superpowers/specs/2026-07-20-workflow-log-streaming-design.md diff --git a/docs/superpowers/plans/2026-07-17-web-console.md b/docs/superpowers/plans/2026-07-17-web-console.md deleted file mode 100644 index 4e8a193..0000000 --- a/docs/superpowers/plans/2026-07-17-web-console.md +++ /dev/null @@ -1,1429 +0,0 @@ -# Vantage Web Console (Guacamole Replacement) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a browser-based SSH/RDP/VNC console to Vantage that connects to managed servers through a `guacd` daemon, with SSH-key selection reusing existing stored keys, plus a Windows agent shipped as an MSI installer. - -**Architecture:** Browser runs `guacamole-common-js` and opens a WebSocket to the Go server. The server (using `github.com/wwt/guac`) brokers a short-lived, HMAC-signed session, then tunnels the Guacamole protocol to a `guacd` container over TCP `:4822`. `guacd` connects directly to the target host's SSH/RDP/VNC port. A reduced-role Windows agent registers and heartbeats but never writes `authorized_keys`. - -**Tech Stack:** Go 1.26 (gin, mongo-driver v2, `github.com/wwt/guac`), Next.js (App Router) + vendored `guacamole-common-js`, `guacamole/guacd` Docker image, WiX v4 MSI, nssm, Gitea Actions. - -## Global Constraints - -- Go version: **1.26** (both `server/go.mod` and `agent/go.mod`). -- Encryption of private material reuses existing AES-256-GCM helpers in `server/internal/services/crypto.go` (`encryptString`/`decryptString`); env var `KEY_ENCRYPTION_KEY` (64-hex). Never add a second crypto path. -- No external CDN in the frontend — `guacamole-common-js` is **vendored** into the repo. -- Decrypted private keys / RDP passwords: never persisted, never logged, never returned to the browser. They go only to `guacd`. -- `guacd` is bound to the internal Docker network only — never published to a host port. -- Existing keys already store `private_key_enc` and agents already upload generated private keys (`UploadGeneratedKey` carries `private_key`). Do **not** re-implement key storage. -- MongoDB collection names are lowercase plural: `servers`, `keys`, `assignments`, `console_sessions`. -- Follow existing service pattern: package-level functions in `server/internal/services/*.go` using `db.Col("")` with a 5s `context.WithTimeout`. -- `os_type` is inferred server-side from `os_info` (which is `" "`) — **no proto change**. - ---- - -## File Structure - -**Server (Go):** -- `server/internal/models/server.go` — add `OSType`, `ConsoleProtocols`, `SSHPort`, `RDPPort`. -- `server/internal/models/console_session.go` — NEW `ConsoleSession` model. -- `server/internal/services/servers.go` — infer `os_type` on register; default console fields. -- `server/internal/services/console.go` — NEW broker: token sign/verify, guacd param build, session lifecycle. -- `server/internal/services/console_test.go` — NEW unit tests. -- `server/internal/api/console.go` — NEW HTTP handlers `POST /api/console/connect`, `GET /api/console/tunnel`. -- `server/internal/api/handlers.go` — register the two new routes; add `/install.ps1` route. -- `server/internal/api/install_windows.go` — NEW `/install.ps1` PowerShell script handler. - -**Agent (Go):** -- `agent/internal/config/config.go` — OS-aware config dir (`C:\ProgramData\vantage` on Windows). -- `agent/internal/sync/sync.go` — guard `authorized_keys` read/write behind `runtime.GOOS == "linux"`. - -**Deploy / CI:** -- `deploy/docker-compose.yml` — add `guacd` service. -- `.gitea/workflows/agent-release.yml` — add windows/amd64 build + WiX MSI + checksums. -- `installer/vantage-agent.wxs` — NEW WiX v4 source. -- `installer/nssm.exe` — bundled nssm payload (downloaded in CI, not committed if large — see Task 12). - -**Frontend (Next.js):** -- `web/lib/guacamole-common.js` — NEW vendored library. -- `web/lib/guacConsole.ts` — NEW thin wrapper: build client, wire WebSocket tunnel. -- `web/app/servers/[id]/console/page.tsx` — NEW console page. -- `web/app/servers/[id]/page.tsx` — add Connect buttons. - ---- - -## Task 1: Extend Server model with console fields - -**Files:** -- Modify: `server/internal/models/server.go` - -**Interfaces:** -- Produces: `Server.OSType string`, `Server.ConsoleProtocols []string`, `Server.SSHPort int`, `Server.RDPPort int`. - -- [ ] **Step 1: Add fields to the Server struct** - -In `server/internal/models/server.go`, add these fields to `type Server struct` (after `OSInfo`): - -```go - OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"` // linux | windows - ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"` // ssh | rdp | vnc - SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"` - RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"` -``` - -- [ ] **Step 2: Build** - -Run: `cd server && go build ./...` -Expected: success, no errors. - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/models/server.go -git commit -m "feat: add console fields to Server model" -``` - ---- - -## Task 2: Infer os_type and default console fields on register - -**Files:** -- Modify: `server/internal/services/servers.go:82` (`RegisterServer`) -- Test: `server/internal/services/servers_console_test.go` (NEW) - -**Interfaces:** -- Consumes: `Server.OSType`, `Server.ConsoleProtocols`, `Server.SSHPort`, `Server.RDPPort` (Task 1). -- Produces: `func OSTypeFromInfo(osInfo string) string` in package `services`. - -- [ ] **Step 1: Write the failing test** - -Create `server/internal/services/servers_console_test.go`: - -```go -package services - -import "testing" - -func TestOSTypeFromInfo(t *testing.T) { - cases := map[string]string{ - "windows amd64": "windows", - "linux amd64": "linux", - "linux arm64": "linux", - "": "linux", - "darwin arm64": "linux", - } - for in, want := range cases { - if got := OSTypeFromInfo(in); got != want { - t.Errorf("OSTypeFromInfo(%q) = %q, want %q", in, got, want) - } - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd server && go test ./internal/services/ -run TestOSTypeFromInfo` -Expected: FAIL — `undefined: OSTypeFromInfo`. - -- [ ] **Step 3: Implement OSTypeFromInfo and wire into RegisterServer** - -Add to `server/internal/services/servers.go` (top-level func): - -```go -// OSTypeFromInfo derives a coarse os_type ("windows" or "linux") from the -// agent-reported os_info string, which is formatted " ". -// Anything that is not explicitly windows defaults to linux. -func OSTypeFromInfo(osInfo string) string { - if strings.HasPrefix(strings.ToLower(osInfo), "windows") { - return "windows" - } - return "linux" -} - -// defaultConsoleFields returns the initial console configuration for a newly -// registered server based on its os_type. -func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) { - if osType == "windows" { - return []string{"rdp"}, 22, 3389 - } - return []string{"ssh"}, 22, 3389 -} -``` - -Ensure `strings` is imported in `servers.go`. Then, inside `RegisterServer`, in the `$set` map that already includes `"os_info": osInfo` (around servers.go:108), add the derived fields: - -```go - osType := OSTypeFromInfo(osInfo) - protocols, sshPort, rdpPort := defaultConsoleFields(osType) -``` - -and add to the same `$set` bson.M: - -```go - "os_type": osType, - "console_protocols": protocols, - "ssh_port": sshPort, - "rdp_port": rdpPort, -``` - -Note: only set console defaults if not already present to avoid clobbering user edits on re-register. Use `$setOnInsert` for `console_protocols`, `ssh_port`, `rdp_port`; keep `os_type` in `$set` (it can legitimately change). If `RegisterServer` currently uses a plain `UpdateOne`/`$set`, split into `$set` (os_type, os_info, status, last_seen) and `$setOnInsert` (the three console fields). - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd server && go test ./internal/services/ -run TestOSTypeFromInfo` -Expected: PASS. - -- [ ] **Step 5: Build** - -Run: `cd server && go build ./...` -Expected: success. - -- [ ] **Step 6: Commit** - -```bash -git add server/internal/services/servers.go server/internal/services/servers_console_test.go -git commit -m "feat: infer os_type and default console config on register" -``` - ---- - -## Task 3: ConsoleSession model - -**Files:** -- Create: `server/internal/models/console_session.go` - -**Interfaces:** -- Produces: `models.ConsoleSession` with fields `SessionID, ServerID, Protocol, KeyID, User, StartedAt, EndedAt, ClientIP`. - -- [ ] **Step 1: Create the model** - -Create `server/internal/models/console_session.go`: - -```go -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -type ConsoleSession struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - SessionID string `bson:"session_id" json:"session_id"` - ServerID string `bson:"server_id" json:"server_id"` - Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc - KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"` - User string `bson:"user" json:"user"` - StartedAt time.Time `bson:"started_at" json:"started_at"` - EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"` - ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"` -} -``` - -- [ ] **Step 2: Build** - -Run: `cd server && go build ./...` -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/models/console_session.go -git commit -m "feat: add ConsoleSession model" -``` - ---- - -## Task 4: Session token sign/verify - -**Files:** -- Create: `server/internal/services/console.go` -- Test: `server/internal/services/console_test.go` - -**Interfaces:** -- Produces: - - `func SignSessionToken(sessionID string, ttl time.Duration) (string, error)` - - `func VerifySessionToken(token string) (sessionID string, err error)` - - Token format: `base64url(sessionID "." expiryUnix) "." base64url(HMAC-SHA256)`. - - HMAC key derived from `KEY_ENCRYPTION_KEY` (reuse `encryptionKey()` from crypto.go). - -- [ ] **Step 1: Write the failing test** - -Create `server/internal/services/console_test.go`: - -```go -package services - -import ( - "testing" - "time" -) - -func TestSessionTokenRoundTrip(t *testing.T) { - t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") - - tok, err := SignSessionToken("sess-123", time.Minute) - if err != nil { - t.Fatalf("sign: %v", err) - } - got, err := VerifySessionToken(tok) - if err != nil { - t.Fatalf("verify: %v", err) - } - if got != "sess-123" { - t.Fatalf("got %q want sess-123", got) - } -} - -func TestSessionTokenExpired(t *testing.T) { - t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") - - tok, err := SignSessionToken("sess-123", -time.Second) - if err != nil { - t.Fatalf("sign: %v", err) - } - if _, err := VerifySessionToken(tok); err == nil { - t.Fatalf("expected expiry error, got nil") - } -} - -func TestSessionTokenTampered(t *testing.T) { - t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") - - tok, _ := SignSessionToken("sess-123", time.Minute) - if _, err := VerifySessionToken(tok + "x"); err == nil { - t.Fatalf("expected signature error, got nil") - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd server && go test ./internal/services/ -run TestSessionToken` -Expected: FAIL — `undefined: SignSessionToken`. - -- [ ] **Step 3: Implement token functions** - -Create `server/internal/services/console.go`: - -```go -package services - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "fmt" - "strconv" - "strings" - "time" -) - -func sessionHMACKey() ([]byte, error) { - // Reuse the AES key material as the HMAC secret. Distinct domain via prefix. - k, err := encryptionKey() - if err != nil { - return nil, err - } - mac := hmac.New(sha256.New, k) - mac.Write([]byte("vantage-console-session-v1")) - return mac.Sum(nil), nil -} - -func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } - -// SignSessionToken returns a signed, expiring token binding a session id. -func SignSessionToken(sessionID string, ttl time.Duration) (string, error) { - key, err := sessionHMACKey() - if err != nil { - return "", err - } - exp := time.Now().Add(ttl).Unix() - payload := fmt.Sprintf("%s.%d", b64([]byte(sessionID)), exp) - mac := hmac.New(sha256.New, key) - mac.Write([]byte(payload)) - return payload + "." + b64(mac.Sum(nil)), nil -} - -// VerifySessionToken checks signature + expiry and returns the session id. -func VerifySessionToken(token string) (string, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return "", fmt.Errorf("malformed token") - } - payload := parts[0] + "." + parts[1] - key, err := sessionHMACKey() - if err != nil { - return "", err - } - mac := hmac.New(sha256.New, key) - mac.Write([]byte(payload)) - want := mac.Sum(nil) - got, err := base64.RawURLEncoding.DecodeString(parts[2]) - if err != nil || !hmac.Equal(want, got) { - return "", fmt.Errorf("invalid signature") - } - exp, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - return "", fmt.Errorf("invalid expiry") - } - if time.Now().Unix() > exp { - return "", fmt.Errorf("token expired") - } - sid, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return "", fmt.Errorf("invalid session id") - } - return string(sid), nil -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd server && go test ./internal/services/ -run TestSessionToken` -Expected: PASS (all three). - -- [ ] **Step 5: Commit** - -```bash -git add server/internal/services/console.go server/internal/services/console_test.go -git commit -m "feat: signed expiring console session tokens" -``` - ---- - -## Task 5: guacd connection parameter builder - -**Files:** -- Modify: `server/internal/services/console.go` -- Test: `server/internal/services/console_test.go` - -**Interfaces:** -- Produces: - - `type GuacParams struct { Protocol string; Params map[string]string }` - - `func BuildGuacParams(srv *models.Server, protocol, privateKey, rdpUser, rdpPass string) (*GuacParams, error)` - -- [ ] **Step 1: Write the failing test** - -Append to `server/internal/services/console_test.go`: - -```go -func TestBuildGuacParamsSSH(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22} - p, err := BuildGuacParams(srv, "ssh", "PRIVATE-KEY-DATA", "", "") - if err != nil { - t.Fatalf("err: %v", err) - } - if p.Protocol != "ssh" { - t.Fatalf("protocol %q", p.Protocol) - } - if p.Params["hostname"] != "10.0.0.5" || p.Params["port"] != "22" { - t.Fatalf("bad host/port: %+v", p.Params) - } - if p.Params["private-key"] != "PRIVATE-KEY-DATA" { - t.Fatalf("missing private-key") - } -} - -func TestBuildGuacParamsRDP(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389} - p, err := BuildGuacParams(srv, "rdp", "", "administrator", "s3cret") - if err != nil { - t.Fatalf("err: %v", err) - } - if p.Params["port"] != "3389" || p.Params["username"] != "administrator" || p.Params["password"] != "s3cret" { - t.Fatalf("bad rdp params: %+v", p.Params) - } - if p.Params["ignore-cert"] != "true" { - t.Fatalf("expected ignore-cert=true") - } -} - -func TestBuildGuacParamsUnknownProtocol(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.9"} - if _, err := BuildGuacParams(srv, "telnet", "", "", ""); err == nil { - t.Fatalf("expected error for unknown protocol") - } -} -``` - -Add `"github.com/mrhid6/vantage/server/internal/models"` to the test imports. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd server && go test ./internal/services/ -run TestBuildGuacParams` -Expected: FAIL — `undefined: BuildGuacParams`. - -- [ ] **Step 3: Implement BuildGuacParams** - -Append to `server/internal/services/console.go` (add `"strconv"` already imported; add `"github.com/mrhid6/vantage/server/internal/models"` import): - -```go -type GuacParams struct { - Protocol string - Params map[string]string -} - -func portOr(v, def int) string { - if v == 0 { - v = def - } - return strconv.Itoa(v) -} - -// BuildGuacParams assembles the guacd connection parameter map for a protocol. -// privateKey is the decrypted SSH private key (ssh only); rdpUser/rdpPass are -// used for rdp. None of these values are persisted or logged by the caller. -func BuildGuacParams(srv *models.Server, protocol, privateKey, rdpUser, rdpPass string) (*GuacParams, error) { - host := srv.IPAddress - switch protocol { - case "ssh": - p := map[string]string{ - "hostname": host, - "port": portOr(srv.SSHPort, 22), - } - if privateKey != "" { - p["private-key"] = privateKey - } - return &GuacParams{Protocol: "ssh", Params: p}, nil - case "rdp": - return &GuacParams{Protocol: "rdp", Params: map[string]string{ - "hostname": host, - "port": portOr(srv.RDPPort, 3389), - "username": rdpUser, - "password": rdpPass, - "security": "any", - "ignore-cert": "true", - }}, nil - case "vnc": - return &GuacParams{Protocol: "vnc", Params: map[string]string{ - "hostname": host, - "port": "5900", - "password": rdpPass, - }}, nil - default: - return nil, fmt.Errorf("unsupported protocol %q", protocol) - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd server && go test ./internal/services/ -run TestBuildGuacParams` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add server/internal/services/console.go server/internal/services/console_test.go -git commit -m "feat: build guacd connection params per protocol" -``` - ---- - -## Task 6: Session lifecycle (create/end) in Mongo - -**Files:** -- Modify: `server/internal/services/console.go` - -**Interfaces:** -- Consumes: `models.ConsoleSession` (Task 3), `db.Col` pattern. -- Produces: - - `func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error)` - - `func EndConsoleSession(sessionID string) error` - - `func GetConsoleSession(sessionID string) (*models.ConsoleSession, error)` - -- [ ] **Step 1: Implement the three functions** - -Append to `server/internal/services/console.go` (add imports `"context"`, `"github.com/google/uuid"`, `"github.com/mrhid6/vantage/server/internal/db"`, `"go.mongodb.org/mongo-driver/v2/bson"`): - -```go -func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - s := &models.ConsoleSession{ - SessionID: uuid.NewString(), - ServerID: serverID, - Protocol: protocol, - KeyID: keyID, - User: user, - ClientIP: clientIP, - StartedAt: time.Now(), - } - if _, err := db.Col("console_sessions").InsertOne(ctx, s); err != nil { - return nil, err - } - return s, nil -} - -func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - var s models.ConsoleSession - if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID}).Decode(&s); err != nil { - return nil, err - } - return &s, nil -} - -func EndConsoleSession(sessionID string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - now := time.Now() - _, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "ended_at": nil}, - bson.M{"$set": bson.M{"ended_at": now}}, - ) - return err -} -``` - -- [ ] **Step 2: Build** - -Run: `cd server && go build ./... && go test ./internal/services/ -run 'TestSessionToken|TestBuildGuacParams'` -Expected: build success, existing tests still PASS. - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/services/console.go -git commit -m "feat: console session lifecycle persistence" -``` - ---- - -## Task 7: Add wwt/guac dependency and guacd to docker-compose - -**Files:** -- Modify: `server/go.mod`, `server/go.sum` -- Modify: `deploy/docker-compose.yml` - -**Interfaces:** -- Produces: `github.com/wwt/guac` available to import; `guacd` service reachable at host `guacd:4822` on the compose network. - -- [ ] **Step 1: Add the Go dependency** - -Run: `cd server && go get github.com/wwt/guac@latest` -Expected: `go.mod`/`go.sum` updated. - -- [ ] **Step 2: Add guacd service to docker-compose** - -In `deploy/docker-compose.yml`, add under `services:` (no `ports:` — internal only): - -```yaml - guacd: - image: guacamole/guacd:1.5.5 - restart: unless-stopped -``` - -Then add to the `server` service `environment:` block: - -```yaml - GUACD_ADDR: guacd:4822 -``` - -And add `guacd` to the `server` service `depends_on:` (simple list form is fine): - -```yaml - guacd: - condition: service_started -``` - -- [ ] **Step 3: Validate compose** - -Run: `cd deploy && docker compose config >/dev/null` -Expected: no error (prints nothing). - -- [ ] **Step 4: Commit** - -```bash -git add server/go.mod server/go.sum deploy/docker-compose.yml -git commit -m "feat: add wwt/guac dep and guacd service" -``` - ---- - -## Task 8: Console API — connect + tunnel handlers - -**Files:** -- Create: `server/internal/api/console.go` -- Modify: `server/internal/api/handlers.go` (register routes) - -**Interfaces:** -- Consumes: `services.CreateConsoleSession`, `services.SignSessionToken`, `services.VerifySessionToken`, `services.GetConsoleSession`, `services.EndConsoleSession`, `services.BuildGuacParams`, `services.GetServer`, `services.GetPrivateKey`, `services.OSTypeFromInfo`. -- Produces: routes `POST /api/console/connect`, `GET /api/console/tunnel`. - -- [ ] **Step 1: Implement the connect handler + tunnel handler** - -Create `server/internal/api/console.go`: - -```go -package api - -import ( - "net/http" - "os" - "time" - - "github.com/gin-gonic/gin" - "github.com/mrhid6/vantage/server/internal/services" - "github.com/wwt/guac" -) - -// POST /api/console/connect -// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? } -// Returns: { session_id, token, ws_path } -func consoleConnect(c *gin.Context) { - var body struct { - ServerID string `json:"server_id" binding:"required"` - Protocol string `json:"protocol" binding:"required"` - KeyID string `json:"key_id"` - RDPUsername string `json:"rdp_username"` - RDPPassword string `json:"rdp_password"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - srv, err := services.GetServer(body.ServerID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) - return - } - - sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - token, err := services.SignSessionToken(sess.SessionID, time.Minute) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "", - "console session opened ("+body.Protocol+")") - - c.JSON(http.StatusOK, gin.H{ - "session_id": sess.SessionID, - "token": token, - "ws_path": "/api/console/tunnel", - }) -} - -// GET /api/console/tunnel?token=... (WebSocket upgrade) -func consoleTunnel(c *gin.Context) { - token := c.Query("token") - sessionID, err := services.VerifySessionToken(token) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - sess, err := services.GetConsoleSession(sessionID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) - return - } - srv, err := services.GetServer(sess.ServerID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) - return - } - - // Decrypt private key in-memory only (ssh). - var privKey string - if sess.Protocol == "ssh" && sess.KeyID != "" { - privKey, err = services.GetPrivateKey(sess.KeyID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"}) - return - } - } - - // RDP creds are single-use, passed via the connect step into the session - // document is avoided; instead they are re-supplied here as query params - // over the already-authenticated WS token. For ssh they are empty. - gp, err := services.BuildGuacParams(srv, sess.Protocol, privKey, c.Query("u"), c.Query("p")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - guacdAddr := os.Getenv("GUACD_ADDR") - if guacdAddr == "" { - guacdAddr = "guacd:4822" - } - - // Build a guac tunnel config from our params. - connect := func(r *http.Request) (guac.Tunnel, error) { - info := guac.NewGuacamoleConfiguration() - info.Protocol = gp.Protocol - for k, v := range gp.Params { - info.Parameters[k] = v - } - // Optional display sizing from client query. - info.OptimalScreenWidth = 1024 - info.OptimalScreenHeight = 768 - info.OptimalResolution = 96 - stream, e := guac.NewInetSocket(guacdAddr) - if e != nil { - return nil, e - } - return guac.NewSimpleTunnel(stream), nil - } - - server := guac.NewWebsocketServer(connect) - server.OnConnect = func(id string, r *http.Request, t guac.Tunnel) {} - server.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) { - _ = services.EndConsoleSession(sessionID) - } - server.ServeHTTP(c.Writer, c.Request) -} -``` - -> Note for implementer: `github.com/wwt/guac`'s exact constructor names (`NewGuacamoleConfiguration`, `NewInetSocket`, `NewSimpleTunnel`, `NewWebsocketServer`, `Tunnel`) must be confirmed against the installed version; adjust to match its README if the API differs. The shape (a `connect` callback returning a `Tunnel` that dials guacd, wrapped in a websocket server) is stable across versions. - -- [ ] **Step 2: Register the routes** - -In `server/internal/api/handlers.go`, inside the `apiGroup` block (session-authed), add: - -```go - apiGroup.POST("/console/connect", consoleConnect) - apiGroup.GET("/console/tunnel", consoleTunnel) -``` - -- [ ] **Step 3: Build** - -Run: `cd server && go build ./...` -Expected: success. If the `guac` API names differ, fix per its README until it builds. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/api/console.go server/internal/api/handlers.go -git commit -m "feat: console connect + guacd websocket tunnel endpoints" -``` - ---- - -## Task 9: OS-aware agent config directory - -**Files:** -- Modify: `agent/internal/config/config.go` - -**Interfaces:** -- Produces: `func ConfigDir() string`, `func configPath() string` — Windows returns `C:\ProgramData\vantage`, else `/etc/vantage`. `ConfigPath` const replaced by function usage. - -- [ ] **Step 1: Write the failing test** - -Create `agent/internal/config/config_test.go`: - -```go -package config - -import ( - "runtime" - "strings" - "testing" -) - -func TestConfigDirByOS(t *testing.T) { - d := ConfigDir() - if runtime.GOOS == "windows" { - if !strings.Contains(strings.ToLower(d), "programdata") { - t.Fatalf("windows config dir = %q, want ProgramData path", d) - } - } else { - if d != "/etc/vantage" { - t.Fatalf("unix config dir = %q, want /etc/vantage", d) - } - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd agent && go test ./internal/config/ -run TestConfigDirByOS` -Expected: FAIL — `undefined: ConfigDir`. - -- [ ] **Step 3: Implement OS-aware paths** - -Edit `agent/internal/config/config.go`. Replace the `const ConfigPath = "/etc/vantage/config.yaml"` line and update `Load`/`Save`: - -```go -import ( - "os" - "path/filepath" - "runtime" - "time" - - "gopkg.in/yaml.v3" -) - -// ConfigDir returns the platform-specific config directory. -func ConfigDir() string { - if runtime.GOOS == "windows" { - base := os.Getenv("ProgramData") - if base == "" { - base = `C:\ProgramData` - } - return filepath.Join(base, "vantage") - } - return "/etc/vantage" -} - -func configPath() string { return filepath.Join(ConfigDir(), "config.yaml") } -``` - -In `Load`, replace `os.ReadFile(ConfigPath)` with `os.ReadFile(configPath())`. -In `Save`, replace `os.MkdirAll("/etc/vantage", 0700)` with `os.MkdirAll(ConfigDir(), 0700)` and `os.WriteFile(ConfigPath, ...)` with `os.WriteFile(configPath(), data, 0600)`. - -Search the rest of the agent for `config.ConfigPath` references and switch them to `config.ConfigDir()`/internal usage: - -Run: `cd agent && grep -rn "ConfigPath" .` — update any hits to use `ConfigDir()` or a new exported helper if needed. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd agent && go test ./internal/config/ -run TestConfigDirByOS` -Expected: PASS. - -- [ ] **Step 5: Cross-compile check for Windows** - -Run: `cd agent && GOOS=windows GOARCH=amd64 go build ./...` -Expected: success. - -- [ ] **Step 6: Commit** - -```bash -git add agent/internal/config/config.go agent/internal/config/config_test.go -git commit -m "feat: OS-aware agent config directory" -``` - ---- - -## Task 10: Guard authorized_keys writes to Linux only - -**Files:** -- Modify: `agent/internal/sync/sync.go` - -**Interfaces:** -- Consumes: `runtime.GOOS`. -- Produces: `poll` still calls `SyncKeys` (heartbeat/last_seen) on all OSes, but only reads/writes `authorized_keys` on Linux. - -- [ ] **Step 1: Guard the write path in poll** - -In `agent/internal/sync/sync.go`, edit `poll` so that after `SyncKeys` succeeds, the `authorized_keys` handling is skipped on non-Linux: - -```go -func poll(client *grpcclient.Client, cfg *config.Config, version string) error { - desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version) - if err != nil { - return fmt.Errorf("SyncKeys: %w", err) - } - - // Windows agents register and heartbeat only — no authorized_keys management. - if runtime.GOOS != "linux" { - return nil - } - - current, err := keys.ReadAuthorizedKeys() - if err != nil { - return fmt.Errorf("read authorized_keys: %w", err) - } - if !keys.StateChanged(current, desired) { - log.Println("authorized_keys unchanged, skipping write") - return nil - } - if err := keys.WriteAuthorizedKeys(desired); err != nil { - return fmt.Errorf("write authorized_keys: %w", err) - } - log.Printf("authorized_keys updated (%d keys)", len(desired)) - return nil -} -``` - -(`runtime` is already imported in sync.go.) - -- [ ] **Step 2: Build both targets** - -Run: -``` -cd agent && go build ./... && GOOS=windows GOARCH=amd64 go build ./... -``` -Expected: both succeed. - -- [ ] **Step 3: Commit** - -```bash -git add agent/internal/sync/sync.go -git commit -m "feat: skip authorized_keys management on non-linux agents" -``` - ---- - -## Task 11: Add Windows build to agent-release workflow - -**Files:** -- Modify: `.gitea/workflows/agent-release.yml` - -**Interfaces:** -- Produces: release asset `vantage-agent-windows-amd64.exe` + its checksum entry. - -- [ ] **Step 1: Add the Windows build to the Build step** - -In `.gitea/workflows/agent-release.yml`, in the `Build` step `run:` block, append: - -```bash - GOOS=windows GOARCH=amd64 go build \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o dist/vantage-agent-windows-amd64.exe ./cmd -``` - -Update the `Checksums` step to include it: - -```bash - sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt -``` - -Add to the release `files:` list: - -```yaml - agent/dist/vantage-agent-windows-amd64.exe -``` - -- [ ] **Step 2: Lint the YAML** - -Run: `cd "$(git rev-parse --show-toplevel)" && python -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/agent-release.yml'))"` -Expected: no error. - -- [ ] **Step 3: Commit** - -```bash -git add .gitea/workflows/agent-release.yml -git commit -m "ci: build windows agent binary in release" -``` - ---- - -## Task 12: WiX MSI source + CI build job - -**Files:** -- Create: `installer/vantage-agent.wxs` -- Modify: `.gitea/workflows/agent-release.yml` - -**Interfaces:** -- Consumes: `agent/dist/vantage-agent-windows-amd64.exe` (Task 11), bundled `nssm.exe`. -- Produces: release asset `vantage-agent.msi`; accepts MSI properties `SERVERID`, `TOKEN`, `SERVERURL`; installs to `C:\Program Files\Vantage\`, writes `C:\ProgramData\vantage\config.yaml`, registers + starts the `VantageAgent` service via nssm. - -- [ ] **Step 1: Create the WiX source** - -Create `installer/vantage-agent.wxs`: - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -Also create `installer/setup.ps1` and add it as a `File`/`Component` (add a third component mirroring the two above, `Source="setup.ps1" Name="setup.ps1"`, and a matching `ComponentRef`): - -```powershell -param( - [string]$ServerId, - [string]$Token, - [string]$ServerUrl, - [string]$InstallDir -) -$cfgDir = Join-Path $env:ProgramData "vantage" -New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null -$cfg = @" -server_url: "$ServerUrl" -server_id: "$ServerId" -pre_reg_token: "$Token" -agent_token: "" -poll_interval: 30s -tls: true -"@ -Set-Content -Path (Join-Path $cfgDir "config.yaml") -Value $cfg -Encoding utf8 -# Lock down ACL: SYSTEM + Administrators only -icacls (Join-Path $cfgDir "config.yaml") /inheritance:r /grant:r "SYSTEM:F" "Administrators:F" | Out-Null - -$nssm = Join-Path $InstallDir "nssm.exe" -$exe = Join-Path $InstallDir "vantage-agent.exe" -& $nssm install VantageAgent $exe -& $nssm set VantageAgent Start SERVICE_AUTO_START -& $nssm start VantageAgent -``` - -- [ ] **Step 2: Add the MSI build job to CI** - -In `.gitea/workflows/agent-release.yml`, add a new job that runs after `build`. It downloads nssm, installs the WiX v4 dotnet tool, and builds the MSI on the Linux runner: - -```yaml - msi: - needs: build - runs-on: ubuntu-docker - container: mcr.microsoft.com/dotnet/sdk:9.0 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download agent exe artifact - # The build job attached the exe to the release; re-fetch it, or - # pass it between jobs via actions/upload-artifact in build and - # actions/download-artifact here. Use artifacts to avoid release ordering. - uses: actions/download-artifact@v4 - with: - name: agent-windows - path: installer - - - name: Fetch nssm - working-directory: installer - run: | - apt-get update && apt-get install -y unzip curl - curl -fsSL -o nssm.zip https://nssm.cc/release/nssm-2.24.zip - unzip -j nssm.zip 'nssm-2.24/win64/nssm.exe' -d . - - - name: Install WiX - run: dotnet tool install --global wix --version 5.* - - - name: Build MSI - working-directory: installer - run: | - export PATH="$PATH:/root/.dotnet/tools" - cp ../agent/dist/vantage-agent-windows-amd64.exe . 2>/dev/null || true - wix build vantage-agent.wxs -o vantage-agent.msi - sha256sum vantage-agent.msi >> checksums-msi.txt - - - name: Attach MSI to release - uses: https://gitea.com/actions/gitea-release-action@v1 - with: - token: ${{ secrets.RELEASE_TOKEN }} - files: | - installer/vantage-agent.msi -``` - -In the `build` job, add before its release step so the exe is available to the `msi` job: - -```yaml - - name: Upload agent exe artifact - uses: actions/upload-artifact@v4 - with: - name: agent-windows - path: agent/dist/vantage-agent-windows-amd64.exe -``` - -> Implementer note: WiX v4/v5 CLI is `wix build`. Confirm the installed major version and pin `--version` accordingly; the `.wxs` uses the v4 schema namespace which v5 also accepts. If `download-artifact` cross-job wiring is awkward on this Gitea runner, instead merge the MSI build into the `build` job after the Windows `go build` (simpler, single job) — the `.wxs` and nssm/WiX steps are identical. - -- [ ] **Step 3: Validate WiX source locally (best-effort)** - -Run: `cd "$(git rev-parse --show-toplevel)" && python -c "import xml.dom.minidom,sys; xml.dom.minidom.parse('installer/vantage-agent.wxs')"` -Expected: no error (well-formed XML). - -- [ ] **Step 4: Commit** - -```bash -git add installer/vantage-agent.wxs installer/setup.ps1 .gitea/workflows/agent-release.yml -git commit -m "ci: package windows agent as WiX MSI" -``` - ---- - -## Task 13: Dynamic /install.ps1 handler - -**Files:** -- Create: `server/internal/api/install_windows.go` -- Modify: `server/internal/api/handlers.go` (add route) - -**Interfaces:** -- Consumes: env `GITEA_HOST`, `GRPC_HOST`/`PUBLIC_HOST` (same pattern as `handleInstallScript`). -- Produces: `GET /install.ps1?server_id=&token=` returns a PowerShell script that downloads the MSI, verifies checksum, and runs `msiexec /qn` with injected properties. - -- [ ] **Step 1: Implement the handler** - -Create `server/internal/api/install_windows.go`: - -```go -package api - -import ( - "fmt" - "net/http" - "os" - - "github.com/gin-gonic/gin" -) - -func handleInstallScriptWindows(c *gin.Context) { - serverID := c.Query("server_id") - token := c.Query("token") - - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } - grpcHost := os.Getenv("GRPC_HOST") - if grpcHost == "" { - grpcHost = os.Getenv("PUBLIC_HOST") - } - if grpcHost == "" { - grpcHost = "vantage.example.com" - } - - script := fmt.Sprintf(`#Requires -RunAsAdministrator -$ErrorActionPreference = "Stop" - -$ServerId = "%s" -$Token = "%s" -$GiteaHost = "%s" -$ServerUrl = "%s" -replace '^https?://','' - -$rel = Invoke-RestMethod -Uri "https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10" -$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name -if (-not $tag) { throw "Could not determine latest agent version" } -$enc = $tag -replace '/','%%2F' -$base = "https://$GiteaHost/mrhid6/vantage/releases/download/$enc" - -$tmp = Join-Path $env:TEMP "vantage-agent.msi" -Invoke-WebRequest -Uri "$base/vantage-agent.msi" -OutFile $tmp -Invoke-WebRequest -Uri "$base/checksums-msi.txt" -OutFile "$env:TEMP\checksums-msi.txt" - -$expected = (Get-Content "$env:TEMP\checksums-msi.txt" | Select-String 'vantage-agent.msi').ToString().Split()[0] -$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower() -if ($expected -ne $actual) { throw "Checksum mismatch" } - -Start-Process msiexec.exe -Wait -ArgumentList "/i `"$tmp`" /qn SERVERID=$ServerId TOKEN=$Token SERVERURL=$ServerUrl" -Write-Host "Vantage agent installed." -`, serverID, token, giteaHost, grpcHost) - - c.Header("Content-Type", "text/plain; charset=utf-8") - c.String(http.StatusOK, script) -} -``` - -- [ ] **Step 2: Register the route** - -In `server/internal/api/handlers.go`, next to `r.GET("/install", handleInstallScript)`, add: - -```go - r.GET("/install.ps1", handleInstallScriptWindows) -``` - -- [ ] **Step 3: Build** - -Run: `cd server && go build ./...` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/api/install_windows.go server/internal/api/handlers.go -git commit -m "feat: dynamic windows install.ps1 endpoint" -``` - ---- - -## Task 14: Vendor guacamole-common-js + console wrapper - -**Files:** -- Create: `web/lib/guacamole-common.js` (vendored) -- Create: `web/lib/guacConsole.ts` - -**Interfaces:** -- Produces: `export function openConsole(container: HTMLElement, wsUrl: string): { disconnect: () => void }`. - -- [ ] **Step 1: Vendor the library** - -Download `guacamole-common-js` (Apache-2.0) `all.min.js` matching guacd 1.5.x and save it as `web/lib/guacamole-common.js`. Prepend a comment line noting version + license. (No CDN reference — the file is committed.) - -Run to confirm it's non-empty: -`cd web && test -s lib/guacamole-common.js && echo ok` -Expected: `ok`. - -- [ ] **Step 2: Write the wrapper** - -Create `web/lib/guacConsole.ts`: - -```ts -// Thin wrapper over the vendored guacamole-common-js client. -// The library attaches a global `Guacamole` object when loaded. -declare const Guacamole: any; - -export function openConsole(container: HTMLElement, wsUrl: string): { disconnect: () => void } { - const tunnel = new Guacamole.WebSocketTunnel(wsUrl); - const client = new Guacamole.Client(tunnel); - - container.innerHTML = ""; - container.appendChild(client.getDisplay().getElement()); - - client.connect(""); - - // Wire keyboard + mouse. - const mouse = new Guacamole.Mouse(client.getDisplay().getElement()); - mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: any) => - client.sendMouseState(state); - const keyboard = new Guacamole.Keyboard(document); - keyboard.onkeydown = (k: number) => client.sendKeyEvent(1, k); - keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k); - - return { - disconnect() { - client.disconnect(); - }, - }; -} -``` - -- [ ] **Step 3: Typecheck** - -Run: `cd web && npx tsc --noEmit` -Expected: no errors from `lib/guacConsole.ts`. - -- [ ] **Step 4: Commit** - -```bash -git add web/lib/guacamole-common.js web/lib/guacConsole.ts -git commit -m "feat: vendor guacamole-common-js and console wrapper" -``` - ---- - -## Task 15: Console page + Connect buttons - -**Files:** -- Create: `web/app/servers/[id]/console/page.tsx` -- Modify: `web/app/servers/[id]/page.tsx` - -**Interfaces:** -- Consumes: `openConsole` (Task 14), `POST /api/console/connect`, keys list (`GET /api/keys`), server detail (`GET /api/servers/:id`). - -- [ ] **Step 1: Build the console page** - -Create `web/app/servers/[id]/console/page.tsx`. Match the existing App-Router + fetch style used elsewhere in `web/app` (check a sibling page such as `web/app/servers/[id]/page.tsx` for the API base + auth pattern, and reuse it). The page must: - -1. Load `Guacamole` by injecting the vendored script once: - ```tsx - useEffect(() => { - const s = document.createElement("script"); - s.src = "/lib/guacamole-common.js"; // served from web/public — see step 2 - s.async = true; - document.body.appendChild(s); - return () => { document.body.removeChild(s); }; - }, []); - ``` -2. Load the server (for `console_protocols`) and keys (`has_private_key === true` only) to populate a protocol ``; for rdp, username/password inputs. -3. On **Connect**, `POST /api/console/connect` with `{ server_id, protocol, key_id?, rdp_username?, rdp_password? }`, receive `{ token, ws_path }`, then build the WS URL: - ```ts - const proto = location.protocol === "https:" ? "wss" : "ws"; - const extra = protocol === "rdp" ? `&u=${encodeURIComponent(rdpUser)}&p=${encodeURIComponent(rdpPass)}` : ""; - const wsUrl = `${proto}://${location.host}${ws_path}?token=${encodeURIComponent(token)}${extra}`; - const conn = openConsole(containerRef.current!, wsUrl); - ``` -4. Render a full-height `
` for the display and a Disconnect button calling `conn.disconnect()`. - -- [ ] **Step 2: Serve the vendored script from public** - -Copy the vendored library into `web/public/lib/` so Next serves it statically: - -Run: `cd web && mkdir -p public/lib && cp lib/guacamole-common.js public/lib/guacamole-common.js` -Expected: file present at `web/public/lib/guacamole-common.js`. - -- [ ] **Step 3: Add Connect buttons to the server detail page** - -In `web/app/servers/[id]/page.tsx`, for each protocol in the server's `console_protocols`, render a link/button to `/servers/${id}/console?protocol=${p}`. Follow the existing button styling in that file. - -- [ ] **Step 4: Build the frontend** - -Run: `cd web && npm run build` -Expected: build succeeds. - -- [ ] **Step 5: Commit** - -```bash -git add web/app/servers/[id]/console/page.tsx web/app/servers/[id]/page.tsx web/public/lib/guacamole-common.js -git commit -m "feat: web console page with protocol + key selection" -``` - ---- - -## Task 16: End-to-end verification - -**Files:** none (verification only). - -- [ ] **Step 1: Run all Go tests** - -Run: `cd server && go test ./... && cd ../agent && go test ./...` -Expected: PASS. - -- [ ] **Step 2: Cross-compile the agent for Windows** - -Run: `cd agent && GOOS=windows GOARCH=amd64 go build ./cmd` -Expected: success. - -- [ ] **Step 3: Bring up the stack and drive one SSH session** - -Run: `cd deploy && docker compose up -d --build` -Then, following the app UI: -1. Add a Linux server, install the agent, confirm it registers (status active). -2. Upload or generate an SSH key that has private material; assign it. -3. Open `/servers/[id]/console`, pick SSH + the key, click Connect. -4. Confirm a live terminal renders and accepts input. -5. Confirm a `console_sessions` document exists with `started_at` and, after closing, `ended_at`. - -Expected: interactive SSH session works end-to-end; audit log shows `console.opened`. - -- [ ] **Step 4: Commit any fixes discovered during verification** - -```bash -git add -A -git commit -m "fix: web console e2e adjustments" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** guacd/wwt/guac tunnel (Tasks 7-8), SSH key reuse via existing `GetPrivateKey` (Task 8), RDP creds (Tasks 5,8), direct network path (Task 8 `guacd:4822`), data model (Tasks 1,3), broker + signed token (Tasks 4-6), Windows agent reduced role (Tasks 9-10), MSI (Task 12), install.ps1 (Task 13), CI windows build (Task 11), frontend (Tasks 14-15), session recording explicitly out of scope — no task. All covered. -- **os_type without proto change:** confirmed — `os_info` already `" "`, inferred in Task 2. -- **Library API caveat:** `wwt/guac` and `guacamole-common-js` exact symbol names are flagged for confirmation at implementation time (Tasks 8, 14) — the surrounding shape is fixed. diff --git a/docs/superpowers/plans/2026-07-20-server-workflows.md b/docs/superpowers/plans/2026-07-20-server-workflows.md deleted file mode 100644 index 2d959cc..0000000 --- a/docs/superpowers/plans/2026-07-20-server-workflows.md +++ /dev/null @@ -1,1823 +0,0 @@ -# Server Workflows Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let operators compose reusable Bash/PowerShell steps into workflows and run them across many servers in parallel, passing data between steps via `$WORKFLOW_ENV`, with full run history and secret injection. - -**Architecture:** Extends the existing bidirectional `CommandStream` gRPC infra. A new `RunStepCmd` command is pushed to agents; agents exec the script with a `$WORKFLOW_ENV` file and reply with a new `StepResult` (stdout/stderr/exit/output_env). The server runner fans out one goroutine per target server (parallel), runs steps serially per server, merges output env forward, and applies per-step failure policy. A pending-result registry correlates `StepResult` back to the awaiting runner by `command_id`. - -**Tech Stack:** Go (gin, mongo-driver v2), hand-written JSON-codec gRPC structs (no protoc), Next.js 16 app-router + react-query + Tailwind, MongoDB. - -## Global Constraints - -- **No tests this iteration** — do not write `*_test.go` or frontend tests. Verify each task with `go build ./...`, `go vet ./...`, and (frontend) `npm run build`. -- gRPC uses a **JSON codec** — proto messages are hand-written Go structs in **two** files that must stay identical: `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`. There is no codegen step. Also update `proto/vantage/v1/vantage.proto` as documentation. -- Mongo access pattern: `db.Col("collection_name")` with `context.WithTimeout`. Follow `server/internal/services/secrets.go`. -- Audit every mutation with `services.LogEvent(action, actor, serverID, targetID, message)`. -- REST handlers: gin, JSON, register under the session-authed `apiGroup` in `server/internal/api/handlers.go` (or a new `RegisterWorkflowRoutes(apiGroup)` called from there). Actor via `actorFromCtx(c)`. -- Frontend: use `@/lib/api` typed client, `@/components/ui` (`Button`, `Card`, `Table`/`Thead`/`Tbody`/`Tr`/`Th`/`Td`), Tailwind tokens (`text-primary`, `text-secondary`, `surface`, `surface-2`, `accent`, `border`, `danger`), react-query for data. -- Secret values must never be written into persisted run logs (`stdout`/`stderr`/`run_env`). Mask by literal replacement before persisting. -- Interpreter values are the literals `"bash"` and `"powershell"` everywhere. -- Go module path: `github.com/mrhid6/vantage`. - ---- - -## Task 1: Proto/pb structs — RunStepCmd + StepResult - -**Files:** -- Modify: `proto/vantage/v1/vantage.proto` -- Modify: `server/internal/grpc/pb/vantage.pb.go` -- Modify: `agent/internal/grpc/pb/vantage.pb.go` - -**Interfaces:** -- Produces: `pb.RunStepCmd{Interpreter string, Script string, Env map[string]string, TimeoutSeconds int}`, `pb.StepResult{CommandId string, ExitCode int, Stdout string, Stderr string, OutputEnv map[string]string}`. `pb.ServerCommand` gains field `RunStep *RunStepCmd`. `pb.AgentMessage` gains field `StepResult *StepResult`. - -- [ ] **Step 1: Document in the proto file** - -In `proto/vantage/v1/vantage.proto`, add to the `ServerCommand` oneof: `RunStepCmd run_step = 6;`. Add to the `AgentMessage` oneof: `StepResult step_result = 5;`. Add the two messages: - -```protobuf -message RunStepCmd { - string interpreter = 1; // "bash" | "powershell" - string script = 2; - map env = 3; - int32 timeout_seconds = 4; -} - -message StepResult { - string command_id = 1; - int32 exit_code = 2; - string stdout = 3; - string stderr = 4; - map output_env = 5; -} -``` - -- [ ] **Step 2: Add structs to server pb file** - -In `server/internal/grpc/pb/vantage.pb.go`, add `RunStep` to `ServerCommand` and `StepResult` to `AgentMessage`, then add the two new structs: - -```go -// in type ServerCommand struct { ... } add: - RunStep *RunStepCmd `json:"run_step,omitempty"` - -// in type AgentMessage struct { ... } add: - StepResult *StepResult `json:"step_result,omitempty"` - -type RunStepCmd struct { - Interpreter string `json:"interpreter"` - Script string `json:"script"` - Env map[string]string `json:"env,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` -} - -type StepResult struct { - CommandId string `json:"command_id"` - ExitCode int `json:"exit_code"` - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - OutputEnv map[string]string `json:"output_env,omitempty"` -} -``` - -- [ ] **Step 3: Mirror the exact same additions into the agent pb file** - -Apply the identical struct field additions and new types to `agent/internal/grpc/pb/vantage.pb.go`. - -- [ ] **Step 4: Verify build** - -Run: `cd server && go build ./... && cd ../agent && go build ./...` -Expected: both succeed, no errors. - -- [ ] **Step 5: Commit** - -```bash -git add proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go -git commit -m "feat(proto): add RunStepCmd and StepResult messages" -``` - ---- - -## Task 2: Pending-result registry (server correlation) - -**Files:** -- Create: `server/internal/services/stepresults.go` - -**Interfaces:** -- Consumes: `pb.StepResult` (Task 1). -- Produces: package-level `var StepResults *stepResultRegistry` with methods `Await(commandID string) <-chan *pb.StepResult`, `Cancel(commandID string)`, `Deliver(res *pb.StepResult)`. - -- [ ] **Step 1: Write the registry** - -```go -package services - -import ( - "sync" - - "github.com/mrhid6/vantage/server/internal/grpc/pb" -) - -type stepResultRegistry struct { - mu sync.Mutex - pending map[string]chan *pb.StepResult -} - -// StepResults correlates agent StepResult replies back to the workflow runner -// goroutine that dispatched the matching RunStepCmd, keyed by command_id. -var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)} - -// Await registers interest in a command's result BEFORE the command is -// dispatched, and returns a buffered channel that receives the single result. -func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult { - ch := make(chan *pb.StepResult, 1) - r.mu.Lock() - r.pending[commandID] = ch - r.mu.Unlock() - return ch -} - -// Cancel removes a pending waiter (call on timeout to avoid leaks). -func (r *stepResultRegistry) Cancel(commandID string) { - r.mu.Lock() - delete(r.pending, commandID) - r.mu.Unlock() -} - -// Deliver routes an incoming StepResult to its waiter, if any. -func (r *stepResultRegistry) Deliver(res *pb.StepResult) { - if res == nil { - return - } - r.mu.Lock() - ch, ok := r.pending[res.CommandId] - if ok { - delete(r.pending, res.CommandId) - } - r.mu.Unlock() - if ok { - ch <- res - } -} -``` - -- [ ] **Step 2: Wire delivery into the CommandStream receive loop** - -In `server/internal/grpc/server.go`, inside the background `stream.Recv()` goroutine (around line 122-133), after the existing `if m.Result != nil { ... }` block, add: - -```go - if m.StepResult != nil { - services.StepResults.Deliver(m.StepResult) - } -``` - -- [ ] **Step 3: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/services/stepresults.go server/internal/grpc/server.go -git commit -m "feat(server): add pending step-result registry and stream delivery" -``` - ---- - -## Task 3: Agent — execute RunStepCmd - -**Files:** -- Create: `agent/internal/exec/exec.go` -- Modify: the agent command-stream loop that handles `ServerCommand` (search: `cmd.GenerateKey != nil` / `cmd.UpdateAgent != nil`; likely `agent/internal/sync/sync.go` or `agent/internal/updates/updates.go`). - -**Interfaces:** -- Consumes: `pb.RunStepCmd` (Task 1). -- Produces: `exec.RunStep(cmd *pb.RunStepCmd) *pb.StepResult` — runs the script, returns populated result. The agent loop sends it back via the existing stream `Send(&pb.AgentMessage{ServerId, AgentToken, StepResult: res})`. - -- [ ] **Step 1: Write the executor** - -```go -package exec - -import ( - "bufio" - "bytes" - "context" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/mrhid6/vantage/agent/internal/grpc/pb" -) - -// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for -// the script to append KEY=value output to, executes it under the requested -// interpreter, and returns captured output plus parsed output env. -func RunStep(cmd *pb.RunStepCmd) *pb.StepResult { - res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}} - - dir, err := os.MkdirTemp("", "vantage-step-") - if err != nil { - res.ExitCode = 1 - res.Stderr = "create temp dir: " + err.Error() - return res - } - defer os.RemoveAll(dir) - - envFile := filepath.Join(dir, "workflow_env") - if err := os.WriteFile(envFile, nil, 0600); err != nil { - res.ExitCode = 1 - res.Stderr = "create env file: " + err.Error() - return res - } - - var scriptPath string - var c *exec.Cmd - timeout := time.Duration(cmd.TimeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 30 * time.Minute - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - switch cmd.Interpreter { - case "powershell": - scriptPath = filepath.Join(dir, "step.ps1") - if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0600); err != nil { - res.ExitCode = 1 - res.Stderr = err.Error() - return res - } - shell := "pwsh" - if runtime.GOOS == "windows" { - if _, err := exec.LookPath("pwsh"); err != nil { - shell = "powershell.exe" - } - } - c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath) - default: // "bash" - scriptPath = filepath.Join(dir, "step.sh") - if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil { - res.ExitCode = 1 - res.Stderr = err.Error() - return res - } - c = exec.CommandContext(ctx, "bash", scriptPath) - } - - c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile) - for k, v := range cmd.Env { - c.Env = append(c.Env, k+"="+v) - } - - var stdout, stderr bytes.Buffer - c.Stdout = &stdout - c.Stderr = &stderr - runErr := c.Run() - - res.Stdout = stdout.String() - res.Stderr = stderr.String() - if ctx.Err() == context.DeadlineExceeded { - res.ExitCode = 124 - res.Stderr += "\n[vantage] step timed out" - } else if ee, ok := runErr.(*exec.ExitError); ok { - res.ExitCode = ee.ExitCode() - } else if runErr != nil { - res.ExitCode = 1 - res.Stderr += "\n[vantage] " + runErr.Error() - } - - res.OutputEnv = parseEnvFile(envFile) - return res -} - -// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines -// without '=' are ignored. -func parseEnvFile(path string) map[string]string { - out := map[string]string{} - f, err := os.Open(path) - if err != nil { - return out - } - defer f.Close() - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for sc.Scan() { - line := sc.Text() - i := strings.IndexByte(line, '=') - if i <= 0 { - continue - } - out[line[:i]] = line[i+1:] - } - return out -} -``` - -- [ ] **Step 2: Handle the command in the agent loop** - -Find the agent's `ServerCommand` handling switch (where `cmd.GenerateKey`, `cmd.UpdateAgent`, `cmd.ApplyUpdates` are dispatched). Add a branch. `stream` is the `pb.Vantage_CommandStreamClient`; `serverID`/`agentToken` are in scope there (match how `AgentReady` was sent): - -```go - if cmd.RunStep != nil { - res := exec.RunStep(cmd.RunStep) - res.CommandId = cmd.CommandId - _ = stream.Send(&pb.AgentMessage{ - ServerId: serverID, - AgentToken: agentToken, - StepResult: res, - }) - continue - } -``` - -Add the import `"github.com/mrhid6/vantage/agent/internal/exec"`. - -- [ ] **Step 3: Verify build** - -Run: `cd agent && go build ./... && go vet ./...` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add agent/internal/exec/exec.go agent/internal/ -git commit -m "feat(agent): execute RunStepCmd with WORKFLOW_ENV capture" -``` - ---- - -## Task 4: Models — steps, workflows, runs - -**Files:** -- Create: `server/internal/models/workflow.go` - -**Interfaces:** -- Produces: structs `WorkflowStep`, `Workflow`, `WorkflowStepRef`, `WorkflowRun`, `ServerRun`, `StepRun` with bson+json tags matching spec §3. - -- [ ] **Step 1: Write the models** - -```go -package models - -import "time" - -type WorkflowStep struct { - ID string `bson:"_id,omitempty" json:"-"` - StepID string `bson:"step_id" json:"step_id"` - Name string `bson:"name" json:"name"` - Description string `bson:"description" json:"description"` - Interpreter string `bson:"interpreter" json:"interpreter"` // "bash" | "powershell" - Script string `bson:"script" json:"script"` - DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"` - SecretRefs []string `bson:"secret_refs" json:"secret_refs"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` -} - -type WorkflowStepRef struct { - StepID string `bson:"step_id" json:"step_id"` - Order int `bson:"order" json:"order"` - OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry" - MaxRetries int `bson:"max_retries" json:"max_retries"` - Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"` -} - -type StepOverride struct { - Script *string `bson:"script,omitempty" json:"script,omitempty"` - SecretRefs []string `bson:"secret_refs,omitempty" json:"secret_refs,omitempty"` -} - -type Workflow struct { - ID string `bson:"_id,omitempty" json:"-"` - WorkflowID string `bson:"workflow_id" json:"workflow_id"` - Name string `bson:"name" json:"name"` - TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"` - Steps []WorkflowStepRef `bson:"steps" json:"steps"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` -} - -// ResolvedStep is a step frozen into a run snapshot (library step + overrides applied). -type ResolvedStep struct { - Order int `bson:"order" json:"order"` - Name string `bson:"name" json:"name"` - Interpreter string `bson:"interpreter" json:"interpreter"` - Script string `bson:"script" json:"script"` - SecretRefs []string `bson:"secret_refs" json:"secret_refs"` - OnFailure string `bson:"on_failure" json:"on_failure"` - MaxRetries int `bson:"max_retries" json:"max_retries"` -} - -type StepRun struct { - Order int `bson:"order" json:"order"` - Name string `bson:"name" json:"name"` - Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped - Attempts int `bson:"attempts" json:"attempts"` - ExitCode int `bson:"exit_code" json:"exit_code"` - Stdout string `bson:"stdout" json:"stdout"` - Stderr string `bson:"stderr" json:"stderr"` - OutputEnv map[string]string `bson:"output_env" json:"output_env"` - StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` - FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` -} - -type ServerRun struct { - ServerID string `bson:"server_id" json:"server_id"` - Hostname string `bson:"hostname" json:"hostname"` - Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped - StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` - FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` - RunEnv map[string]string `bson:"run_env" json:"run_env"` - Steps []StepRun `bson:"steps" json:"steps"` -} - -type WorkflowRun struct { - ID string `bson:"_id,omitempty" json:"-"` - RunID string `bson:"run_id" json:"run_id"` - WorkflowID string `bson:"workflow_id" json:"workflow_id"` - Name string `bson:"name" json:"name"` - Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"` - Status string `bson:"status" json:"status"` // running|success|failed|cancelled - TriggeredBy string `bson:"triggered_by" json:"triggered_by"` - StartedAt time.Time `bson:"started_at" json:"started_at"` - FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` - ServerRuns []ServerRun `bson:"server_runs" json:"server_runs"` -} -``` - -- [ ] **Step 2: Verify build** - -Run: `cd server && go build ./...` -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/models/workflow.go -git commit -m "feat(models): add workflow, step, and run models" -``` - ---- - -## Task 5: Step library + workflow CRUD services - -**Files:** -- Create: `server/internal/services/workflows.go` - -**Interfaces:** -- Consumes: models (Task 4), `db.Col`. -- Produces: - - `EnsureWorkflowIndexes() error` - - `ListSteps() ([]models.WorkflowStep, error)`, `CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error)`, `UpdateStep(stepID string, s models.WorkflowStep) error`, `DeleteStep(stepID string) error` - - `ListWorkflows() ([]models.Workflow, error)`, `GetWorkflow(id string) (*models.Workflow, error)`, `CreateWorkflow(w models.Workflow) (*models.Workflow, error)`, `UpdateWorkflow(id string, w models.Workflow) error`, `DeleteWorkflow(id string) error` - -- [ ] **Step 1: Write CRUD service** - -```go -package services - -import ( - "context" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/mrhid6/vantage/server/internal/db" - "github.com/mrhid6/vantage/server/internal/models" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -func wfCtx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 10*time.Second) -} - -func EnsureWorkflowIndexes() error { - ctx, cancel := wfCtx() - defer cancel() - if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "step_id", Value: 1}}, Options: options.Index().SetUnique(true), - }); err != nil { - return err - } - if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "workflow_id", Value: 1}}, Options: options.Index().SetUnique(true), - }); err != nil { - return err - } - _, err := db.Col("workflow_runs").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true), - }) - return err -} - -// ---- Steps ---- - -func ListSteps() ([]models.WorkflowStep, error) { - ctx, cancel := wfCtx() - defer cancel() - cur, err := db.Col("workflow_steps").Find(ctx, bson.M{}, - options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) - if err != nil { - return nil, err - } - defer cur.Close(ctx) - steps := []models.WorkflowStep{} - if err := cur.All(ctx, &steps); err != nil { - return nil, err - } - return steps, nil -} - -func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { - ctx, cancel := wfCtx() - defer cancel() - s.StepID = uuid.New().String() - s.CreatedAt = time.Now() - s.UpdatedAt = s.CreatedAt - if s.DeclaredOutputs == nil { - s.DeclaredOutputs = []string{} - } - if s.SecretRefs == nil { - s.SecretRefs = []string{} - } - if _, err := db.Col("workflow_steps").InsertOne(ctx, s); err != nil { - return nil, err - } - return &s, nil -} - -func UpdateStep(stepID string, s models.WorkflowStep) error { - ctx, cancel := wfCtx() - defer cancel() - _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{ - "name": s.Name, - "description": s.Description, - "interpreter": s.Interpreter, - "script": s.Script, - "declared_outputs": s.DeclaredOutputs, - "secret_refs": s.SecretRefs, - "updated_at": time.Now(), - }}) - return err -} - -func DeleteStep(stepID string) error { - ctx, cancel := wfCtx() - defer cancel() - _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}) - return err -} - -func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) { - var s models.WorkflowStep - err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s) - if err == mongo.ErrNoDocuments { - return nil, fmt.Errorf("step %s not found", stepID) - } - return &s, err -} - -// ---- Workflows ---- - -func ListWorkflows() ([]models.Workflow, error) { - ctx, cancel := wfCtx() - defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{}, - options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) - if err != nil { - return nil, err - } - defer cur.Close(ctx) - wfs := []models.Workflow{} - if err := cur.All(ctx, &wfs); err != nil { - return nil, err - } - return wfs, nil -} - -func GetWorkflow(id string) (*models.Workflow, error) { - ctx, cancel := wfCtx() - defer cancel() - var w models.Workflow - err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w) - if err == mongo.ErrNoDocuments { - return nil, fmt.Errorf("workflow not found") - } - return &w, err -} - -func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { - ctx, cancel := wfCtx() - defer cancel() - w.WorkflowID = uuid.New().String() - w.CreatedAt = time.Now() - w.UpdatedAt = w.CreatedAt - if w.TargetServerIDs == nil { - w.TargetServerIDs = []string{} - } - if w.Steps == nil { - w.Steps = []models.WorkflowStepRef{} - } - if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil { - return nil, err - } - return &w, nil -} - -func UpdateWorkflow(id string, w models.Workflow) error { - ctx, cancel := wfCtx() - defer cancel() - _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{ - "name": w.Name, - "target_server_ids": w.TargetServerIDs, - "steps": w.Steps, - "updated_at": time.Now(), - }}) - return err -} - -func DeleteWorkflow(id string) error { - ctx, cancel := wfCtx() - defer cancel() - _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id}) - return err -} -``` - -- [ ] **Step 2: Register indexes at startup** - -Find where `EnsureSecretIndexes()` is called (search `EnsureSecretIndexes` in `server/cmd/main.go`) and add `EnsureWorkflowIndexes()` alongside it with the same error handling. - -- [ ] **Step 3: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/services/workflows.go server/cmd/main.go -git commit -m "feat(server): step library and workflow CRUD services" -``` - ---- - -## Task 6: Workflow runner (orchestration) - -**Files:** -- Create: `server/internal/services/workflow_runner.go` - -**Interfaces:** -- Consumes: `Dispatcher` (dispatch.go), `StepResults` (Task 2), `GetSecretGroupDecrypted`/secrets, `getStep` (Task 5), models (Task 4), `pb`. -- Produces: `TriggerWorkflow(workflowID, actor string) (string, error)` returning the new `run_id`; `GetRun(runID string) (*models.WorkflowRun, error)`; `ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error)`; `CancelRun(runID string) error`. - -Notes: -- The dispatcher is fire-and-forget; add a small dispatch helper that pushes a `ServerCommand{RunStep}` for a given server. Reuse `Dispatcher` via a new exported method or replicate the `dispatch` pattern. Add to `dispatch.go`: - -```go -// DispatchRunStep pushes a RunStepCmd to a server's agent. Caller must have -// registered StepResults.Await(commandID) first. -func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error { - return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd}) -} -``` - -- [ ] **Step 1: Add `DispatchRunStep` to `dispatch.go`** - -Add the function above to `server/internal/services/dispatch.go` (it needs no new imports; `pb` is already imported). - -- [ ] **Step 2: Write the runner** - -```go -package services - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mrhid6/vantage/server/internal/db" - "github.com/mrhid6/vantage/server/internal/grpc/pb" - "github.com/mrhid6/vantage/server/internal/models" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -const stepDispatchGrace = 15 * time.Second - -// TriggerWorkflow snapshots the workflow, creates a run doc, and starts a -// background goroutine per target server (parallel fan-out). Returns run_id. -func TriggerWorkflow(workflowID, actor string) (string, error) { - wf, err := GetWorkflow(workflowID) - if err != nil { - return "", err - } - if len(wf.TargetServerIDs) == 0 { - return "", fmt.Errorf("workflow has no target servers") - } - if len(wf.Steps) == 0 { - return "", fmt.Errorf("workflow has no steps") - } - - // Reject a concurrent run of the same workflow. - ctx, cancel := wfCtx() - running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"}) - cancel() - if running.Err() == nil { - return "", fmt.Errorf("workflow already has a run in progress") - } - - resolved, err := resolveSteps(wf) - if err != nil { - return "", err - } - - run := models.WorkflowRun{ - RunID: uuid.New().String(), - WorkflowID: workflowID, - Name: wf.Name, - Steps: resolved, - Status: "running", - TriggeredBy: actor, - StartedAt: time.Now(), - ServerRuns: make([]models.ServerRun, 0, len(wf.TargetServerIDs)), - } - for _, sid := range wf.TargetServerIDs { - hostname := sid - if s, e := GetServer(sid); e == nil { - hostname = s.Hostname - } - sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}} - for _, rs := range resolved { - sr.Steps = append(sr.Steps, models.StepRun{Order: rs.Order, Name: rs.Name, Status: "queued", OutputEnv: map[string]string{}}) - } - run.ServerRuns = append(run.ServerRuns, sr) - } - - ictx, icancel := wfCtx() - defer icancel() - if _, err := db.Col("workflow_runs").InsertOne(ictx, run); err != nil { - return "", err - } - - go executeRun(run.RunID) - return run.RunID, nil -} - -// resolveSteps freezes each workflow step ref into a ResolvedStep by loading the -// library step and applying overrides. -func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { - ctx, cancel := wfCtx() - defer cancel() - out := make([]models.ResolvedStep, 0, len(wf.Steps)) - for _, ref := range wf.Steps { - lib, err := getStep(ctx, ref.StepID) - if err != nil { - return nil, err - } - rs := models.ResolvedStep{ - Order: ref.Order, - Name: lib.Name, - Interpreter: lib.Interpreter, - Script: lib.Script, - SecretRefs: lib.SecretRefs, - OnFailure: ref.OnFailure, - MaxRetries: ref.MaxRetries, - } - if ref.Overrides != nil { - if ref.Overrides.Script != nil { - rs.Script = *ref.Overrides.Script - } - if ref.Overrides.SecretRefs != nil { - rs.SecretRefs = ref.Overrides.SecretRefs - } - } - if rs.OnFailure == "" { - rs.OnFailure = "stop" - } - out = append(out, rs) - } - return out, nil -} - -// executeRun fans out one goroutine per server run and waits for all to finish. -func executeRun(runID string) { - run, err := GetRun(runID) - if err != nil { - return - } - done := make(chan int, len(run.ServerRuns)) - for i := range run.ServerRuns { - go func(idx int) { - runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID) - done <- idx - }(i) - } - for range run.ServerRuns { - <-done - } - - // Aggregate status. - final, _ := GetRun(runID) - status := "success" - for _, sr := range final.ServerRuns { - if sr.Status == "failed" { - status = "failed" - } - } - now := time.Now() - ctx, cancel := wfCtx() - defer cancel() - _, _ = db.Col("workflow_runs").UpdateOne(ctx, bson.M{"run_id": runID}, - bson.M{"$set": bson.M{"status": status, "finished_at": now}}) -} - -// runServer executes the resolved steps sequentially on one server, threading -// output env forward and applying per-step failure policy. -func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { - now := time.Now() - setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now}) - - if !Dispatcher.IsConnected(serverID) { - fin := time.Now() - setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "skipped", "server_runs.$.finished_at": fin}) - return - } - - runEnv := map[string]string{} - serverFailed := false - - for i, step := range steps { - startStep(runID, serverID, i, "running") - var res *pb.StepResult - attempts := 0 - maxAttempts := 1 - if step.OnFailure == "retry" { - maxAttempts = step.MaxRetries + 1 - } - - // Merge secrets into command env (kept out of persisted logs). - secretVals := resolveSecrets(step.SecretRefs) - cmdEnv := map[string]string{} - for k, v := range runEnv { - cmdEnv[k] = v - } - for k, v := range secretVals { - cmdEnv[k] = v - } - - for attempts < maxAttempts { - attempts++ - res = dispatchAndWait(serverID, &pb.RunStepCmd{ - Interpreter: step.Interpreter, - Script: step.Script, - Env: cmdEnv, - TimeoutSeconds: 0, - }) - if res != nil && res.ExitCode == 0 { - break - } - } - - // Mask secret values before persisting. - stdout, stderr := "", "" - exit := 1 - outEnv := map[string]string{} - if res != nil { - stdout = maskSecrets(res.Stdout, secretVals) - stderr = maskSecrets(res.Stderr, secretVals) - exit = res.ExitCode - for k, v := range res.OutputEnv { - outEnv[k] = v - runEnv[k] = v // implicit: all outputs flow to all later steps - } - } else { - stderr = "[vantage] agent did not return a result" - } - - status := "success" - if exit != 0 { - status = "failed" - } - finishStep(runID, serverID, i, status, attempts, exit, stdout, stderr, outEnv) - - if exit != 0 { - switch step.OnFailure { - case "continue": - // keep going - default: // "stop" or exhausted "retry" - serverFailed = true - } - if serverFailed { - markRemainingSkipped(runID, serverID, i+1) - break - } - } - } - - fin := time.Now() - status := "success" - if serverFailed { - status = "failed" - } - setServerRun(runID, srvIdx, bson.M{ - "server_runs.$.status": status, - "server_runs.$.finished_at": fin, - "server_runs.$.run_env": runEnv, - }) -} - -// dispatchAndWait registers a waiter, dispatches the step, and blocks for the -// result or a timeout. -func dispatchAndWait(serverID string, cmd *pb.RunStepCmd) *pb.StepResult { - commandID := uuid.New().String() - ch := StepResults.Await(commandID) - if err := DispatchRunStep(serverID, commandID, cmd); err != nil { - StepResults.Cancel(commandID) - return &pb.StepResult{ExitCode: 1, Stderr: "[vantage] dispatch failed: " + err.Error()} - } - wait := time.Duration(cmd.TimeoutSeconds)*time.Second + stepDispatchGrace - if cmd.TimeoutSeconds == 0 { - wait = 30*time.Minute + stepDispatchGrace - } - select { - case res := <-ch: - return res - case <-time.After(wait): - StepResults.Cancel(commandID) - return &pb.StepResult{ExitCode: 124, Stderr: "[vantage] timed out waiting for agent result"} - } -} - -func resolveSecrets(refs []string) map[string]string { - out := map[string]string{} - for _, ref := range refs { - // ref format "group/KEY"; resolve via RevealSecret. - parts := strings.SplitN(ref, "/", 2) - if len(parts) != 2 { - continue - } - if v, err := RevealSecret(parts[0], parts[1]); err == nil { - out[parts[1]] = v - } - } - return out -} - -func maskSecrets(s string, secrets map[string]string) string { - for _, v := range secrets { - if v == "" { - continue - } - s = strings.ReplaceAll(s, v, "***") - } - return s -} - -// ---- run doc mutation helpers ---- - -func setServerRun(runID string, srvIdx int, set bson.M) { - ctx, cancel := wfCtx() - defer cancel() - _, _ = db.Col("workflow_runs").UpdateOne(ctx, - bson.M{"run_id": runID, "server_runs.server_id": serverIDAt(runID, srvIdx)}, - bson.M{"$set": set}) -} - -// serverIDAt returns the server_id at an index (positional operator needs a match). -func serverIDAt(runID string, srvIdx int) string { - r, err := GetRun(runID) - if err != nil || srvIdx >= len(r.ServerRuns) { - return "" - } - return r.ServerRuns[srvIdx].ServerID -} - -func startStep(runID, serverID string, order int, status string) { - now := time.Now() - updateStep(runID, serverID, order, bson.M{ - "server_runs.$[s].steps.$[t].status": status, - "server_runs.$[s].steps.$[t].started_at": now, - }) -} - -func finishStep(runID, serverID string, order int, status string, attempts, exit int, stdout, stderr string, outEnv map[string]string) { - now := time.Now() - updateStep(runID, serverID, order, bson.M{ - "server_runs.$[s].steps.$[t].status": status, - "server_runs.$[s].steps.$[t].attempts": attempts, - "server_runs.$[s].steps.$[t].exit_code": exit, - "server_runs.$[s].steps.$[t].stdout": stdout, - "server_runs.$[s].steps.$[t].stderr": stderr, - "server_runs.$[s].steps.$[t].output_env": outEnv, - "server_runs.$[s].steps.$[t].finished_at": now, - }) -} - -func markRemainingSkipped(runID, serverID string, fromOrder int) { - ctx, cancel := wfCtx() - defer cancel() - _, _ = db.Col("workflow_runs").UpdateMany(ctx, - bson.M{"run_id": runID}, - bson.M{"$set": bson.M{"server_runs.$[s].steps.$[t].status": "skipped"}}, - options.UpdateMany().SetArrayFilters([]interface{}{ - bson.M{"s.server_id": serverID}, - bson.M{"t.order": bson.M{"$gte": fromOrder}, "t.status": "queued"}, - }), - ) -} - -func updateStep(runID, serverID string, order int, set bson.M) { - ctx, cancel := wfCtx() - defer cancel() - _, _ = db.Col("workflow_runs").UpdateOne(ctx, - bson.M{"run_id": runID}, - bson.M{"$set": set}, - options.UpdateOne().SetArrayFilters([]interface{}{ - bson.M{"s.server_id": serverID}, - bson.M{"t.order": order}, - }), - ) -} - -// ---- reads ---- - -func GetRun(runID string) (*models.WorkflowRun, error) { - ctx, cancel := wfCtx() - defer cancel() - var r models.WorkflowRun - err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&r) - if err == mongo.ErrNoDocuments { - return nil, fmt.Errorf("run not found") - } - return &r, err -} - -func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) { - ctx, cancel := wfCtx() - defer cancel() - cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID}, - options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit)) - if err != nil { - return nil, err - } - defer cur.Close(ctx) - runs := []models.WorkflowRun{} - if err := cur.All(ctx, &runs); err != nil { - return nil, err - } - return runs, nil -} - -func CancelRun(runID string) error { - now := time.Now() - ctx, cancel := wfCtx() - defer cancel() - _, err := db.Col("workflow_runs").UpdateOne(ctx, - bson.M{"run_id": runID, "status": "running"}, - bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}}) - return err -} -``` - -Note on `setServerRun`: the positional `$` requires the query to match the array element, so it queries `server_runs.server_id`. `serverIDAt` resolves the id from the index (steps use `srvIdx` only to find the id, then everything else keys off `serverID`). - -- [ ] **Step 3: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. Fix any unused-import or signature mismatches surfaced. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/services/workflow_runner.go server/internal/services/dispatch.go -git commit -m "feat(server): workflow runner with parallel fan-out and env threading" -``` - ---- - -## Task 7: REST API + routes - -**Files:** -- Create: `server/internal/api/workflows.go` -- Modify: `server/internal/api/handlers.go` (register routes) - -**Interfaces:** -- Consumes: services (Tasks 5, 6). -- Produces: HTTP endpoints per spec §8. - -- [ ] **Step 1: Write handlers** - -```go -package api - -import ( - "fmt" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/mrhid6/vantage/server/internal/models" - "github.com/mrhid6/vantage/server/internal/services" -) - -func registerWorkflowRoutes(g *gin.RouterGroup) { - g.GET("/steps", listSteps) - g.POST("/steps", createStep) - g.PUT("/steps/:id", updateStep) - g.DELETE("/steps/:id", deleteStep) - - g.GET("/workflows", listWorkflows) - g.POST("/workflows", createWorkflow) - g.GET("/workflows/:id", getWorkflow) - g.PUT("/workflows/:id", updateWorkflow) - g.DELETE("/workflows/:id", deleteWorkflow) - g.POST("/workflows/:id/run", runWorkflow) - g.GET("/workflows/:id/runs", listWorkflowRuns) - - g.GET("/runs/:runId", getRun) - g.POST("/runs/:runId/cancel", cancelRun) -} - -func listSteps(c *gin.Context) { - steps, err := services.ListSteps() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, steps) -} - -func createStep(c *gin.Context) { - var s models.WorkflowStep - if err := c.ShouldBindJSON(&s); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - out, err := services.CreateStep(s) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) - c.JSON(http.StatusCreated, out) -} - -func updateStep(c *gin.Context) { - var s models.WorkflowStep - if err := c.ShouldBindJSON(&s); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := services.UpdateStep(c.Param("id"), s); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") - c.JSON(http.StatusOK, gin.H{"updated": true}) -} - -func deleteStep(c *gin.Context) { - if err := services.DeleteStep(c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -func listWorkflows(c *gin.Context) { - wfs, err := services.ListWorkflows() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, wfs) -} - -func createWorkflow(c *gin.Context) { - var w models.Workflow - if err := c.ShouldBindJSON(&w); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - out, err := services.CreateWorkflow(w) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) - c.JSON(http.StatusCreated, out) -} - -func getWorkflow(c *gin.Context) { - w, err := services.GetWorkflow(c.Param("id")) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, w) -} - -func updateWorkflow(c *gin.Context) { - var w models.Workflow - if err := c.ShouldBindJSON(&w); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := services.UpdateWorkflow(c.Param("id"), w); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") - c.JSON(http.StatusOK, gin.H{"updated": true}) -} - -func deleteWorkflow(c *gin.Context) { - if err := services.DeleteWorkflow(c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -func runWorkflow(c *gin.Context) { - runID, err := services.TriggerWorkflow(c.Param("id"), actorFromCtx(c)) - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return - } - services.LogEvent("workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) - c.JSON(http.StatusAccepted, gin.H{"run_id": runID}) -} - -func listWorkflowRuns(c *gin.Context) { - limit := int64(50) - if l := c.Query("limit"); l != "" { - if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 { - limit = n - } - } - runs, err := services.ListRuns(c.Param("id"), limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, runs) -} - -func getRun(c *gin.Context) { - r, err := services.GetRun(c.Param("runId")) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, r) -} - -func cancelRun(c *gin.Context) { - if err := services.CancelRun(c.Param("runId")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"cancelled": true}) -} -``` - -- [ ] **Step 2: Register the group** - -In `server/internal/api/handlers.go`, inside the `apiGroup { ... }` block (after the console routes, before the closing brace), add: - -```go - registerWorkflowRoutes(apiGroup) -``` - -- [ ] **Step 3: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/api/workflows.go server/internal/api/handlers.go -git commit -m "feat(api): workflow, step, and run REST endpoints" -``` - ---- - -## Task 8: API client + types (frontend) - -**Files:** -- Modify: `web/lib/api.ts` - -**Interfaces:** -- Produces: TS types `WorkflowStep`, `WorkflowStepRef`, `Workflow`, `WorkflowRun`, `ServerRun`, `StepRun`; `api` methods for all Task 7 endpoints. - -- [ ] **Step 1: Add types and methods** - -Match the existing `api` object style in `web/lib/api.ts` (same fetch/`apiFetch` helper the other methods use — inspect the file and reuse it). Add: - -```ts -export interface WorkflowStep { - step_id: string; - name: string; - description: string; - interpreter: "bash" | "powershell"; - script: string; - declared_outputs: string[]; - secret_refs: string[]; -} - -export interface WorkflowStepRef { - step_id: string; - order: number; - on_failure: "stop" | "continue" | "retry"; - max_retries: number; - overrides?: { script?: string; secret_refs?: string[] }; -} - -export interface Workflow { - workflow_id: string; - name: string; - target_server_ids: string[]; - steps: WorkflowStepRef[]; -} - -export interface StepRun { - order: number; - name: string; - status: string; - attempts: number; - exit_code: number; - stdout: string; - stderr: string; - output_env: Record; - started_at?: string; - finished_at?: string; -} - -export interface ServerRun { - server_id: string; - hostname: string; - status: string; - run_env: Record; - steps: StepRun[]; - started_at?: string; - finished_at?: string; -} - -export interface WorkflowRun { - run_id: string; - workflow_id: string; - name: string; - status: string; - triggered_by: string; - started_at: string; - finished_at?: string; - server_runs: ServerRun[]; -} -``` - -Then add methods to the `api` object (use the file's existing request helper; shown here with a generic `req`): - -```ts - listSteps: () => req("/api/steps"), - createStep: (s: Partial) => req("/api/steps", { method: "POST", body: JSON.stringify(s) }), - updateStep: (id: string, s: Partial) => req(`/api/steps/${id}`, { method: "PUT", body: JSON.stringify(s) }), - deleteStep: (id: string) => req(`/api/steps/${id}`, { method: "DELETE" }), - - listWorkflows: () => req("/api/workflows"), - getWorkflow: (id: string) => req(`/api/workflows/${id}`), - createWorkflow: (w: Partial) => req("/api/workflows", { method: "POST", body: JSON.stringify(w) }), - updateWorkflow: (id: string, w: Partial) => req(`/api/workflows/${id}`, { method: "PUT", body: JSON.stringify(w) }), - deleteWorkflow: (id: string) => req(`/api/workflows/${id}`, { method: "DELETE" }), - runWorkflow: (id: string) => req<{ run_id: string }>(`/api/workflows/${id}/run`, { method: "POST" }), - listRuns: (id: string) => req(`/api/workflows/${id}/runs`), - getRun: (runId: string) => req(`/api/runs/${runId}`), - cancelRun: (runId: string) => req(`/api/runs/${runId}/cancel`, { method: "POST" }), -``` - -Adapt `req`/method names to whatever the file already defines (e.g. it may use `apiFetch` or per-verb helpers). Keep the existing patterns. - -- [ ] **Step 2: Verify build** - -Run: `cd web && npm run build` -Expected: type-checks and builds. Fix type mismatches against the real helper signature. - -- [ ] **Step 3: Commit** - -```bash -git add web/lib/api.ts -git commit -m "feat(web): workflow API client types and methods" -``` - ---- - -## Task 9: Workflows list page + sidebar link - -**Files:** -- Create: `web/app/workflows/page.tsx` -- Modify: `web/components/Sidebar.tsx` (add a Workflows nav item next to Secrets/Servers) - -**Interfaces:** -- Consumes: `api.listWorkflows`, `api.listRuns`, `api.runWorkflow`, `api.createWorkflow` (Task 8). - -- [ ] **Step 1: Add sidebar link** - -In `web/components/Sidebar.tsx`, add a nav entry `{ href: "/workflows", label: "Workflows" }` following the existing item structure/icon pattern used for Servers and Secrets. - -- [ ] **Step 2: Write the list page** - -```tsx -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, Workflow } from "@/lib/api"; -import { Button, Card } from "@/components/ui"; -import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; - -export default function WorkflowsPage() { - const qc = useQueryClient(); - const [creating, setCreating] = useState(false); - - const { data: workflows, isLoading, error } = useQuery({ - queryKey: ["workflows"], - queryFn: api.listWorkflows, - }); - - const { mutate: create, isPending } = useMutation({ - mutationFn: () => api.createWorkflow({ name: "Untitled workflow", target_server_ids: [], steps: [] }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["workflows"] }); - setCreating(false); - }, - }); - - return ( -
-
-
-

Workflows

-

- {workflows?.length ?? 0} workflow{workflows?.length !== 1 ? "s" : ""} · run reusable steps across servers -

-
- -
- - - {isLoading ? ( -
-
-
- ) : error ? ( -
Failed to load workflows.
- ) : workflows && workflows.length > 0 ? ( - - - - - - {workflows.map((w: Workflow) => ( - - - - - - - ))} - -
NameTargetsSteps
{w.name}{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}{w.steps.length} - - - -
- ) : ( -
-

No workflows yet.

- -
- )} - -
- ); -} -``` - -- [ ] **Step 3: Verify build** - -Run: `cd web && npm run build` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add web/app/workflows/page.tsx web/components/Sidebar.tsx -git commit -m "feat(web): workflows list page and sidebar link" -``` - ---- - -## Task 10: Workflow builder page (three-pane) - -**Files:** -- Create: `web/app/workflows/[id]/page.tsx` - -**Interfaces:** -- Consumes: `api.getWorkflow`, `api.updateWorkflow`, `api.listSteps`, `api.createStep`, `api.runWorkflow`, `api.listServers`, `api.listSecretGroups`. - -Reference the approved mockup (`workflow-builder.html`) for layout: left library, center canvas of ordered nodes with env chips on wires, right inspector. Implement with Tailwind tokens; drag can be simplified to add/reorder buttons for v1 (HTML5 drag optional). - -- [ ] **Step 1: Write the builder page** - -Implement a client component with three columns (CSS grid `grid-cols-[264px_1fr_320px]`): -- **Left (Library):** `api.listSteps()` list with `bash`/`pwsh` badges; an "Add" button opens an inline form calling `api.createStep`; clicking a library step appends a `WorkflowStepRef` to local workflow state. -- **Center (Canvas):** render `workflow.steps` (sorted by `order`) as node cards showing the resolved step name, interpreter badge, and the step's script preview. Between nodes render a "passes" chip row derived from each step's `declared_outputs` (union of all prior outputs). Provide up/down reorder and remove buttons. A header shows workflow name (editable input), a target-servers multiselect (`api.listServers`), Save (`api.updateWorkflow`) and Run (`api.runWorkflow`, then route to the run detail page). -- **Right (Inspector):** for the selected node: name, script editor (`