feat: Updated workflow runs page
Server Deploy / deploy (push) Successful in 1m20s

This commit is contained in:
2026-07-20 16:00:41 +01:00
parent 39348c9491
commit 397016ad68
11 changed files with 456 additions and 5786 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,747 +0,0 @@
# Workflow Builder v2 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:** Rebuild the workflow builder to match the approved mockup with drag-and-drop, base-step editing/deletion (cascade), step input parameters, an edit-workflow modal, runs navigation, and fix the save crash.
**Architecture:** Backend gains input-parameter fields on step/ref/resolved models, cascade delete, runner env injection, and a workflow-update handler that returns the updated workflow. Frontend adds `bash`/`pwsh`/`signal` tokens and a `Modal` primitive, then rebuilds the builder page (dotted canvas, 340px node cards, wire env chips, kicker/field inspector, HTML5 drag-and-drop), plus Edit-base-step and Edit-workflow modals and a runs list page.
**Tech Stack:** Go (gin, mongo-driver v2), Next.js 16 app-router + react-query + Tailwind, HTML5 Drag-and-Drop, MongoDB.
## Global Constraints
- **No tests this iteration** — no `*_test.go` or frontend tests. Verify with `go build ./...`, `go vet ./...`, `npm run build`.
- Mongo access pattern: `db.Col("collection_name")` + `context.WithTimeout`. Follow `server/internal/services/workflows.go`.
- Audit every mutation with `services.LogEvent(action, actor, serverID, targetID, message)`.
- Interpreter literals are `"bash"` and `"powershell"`.
- Go module path: `github.com/mrhid6/vantage`.
- Visual target: approved mockup. Builder palette adds amber signal `#f5a524` (`signal`), `#241800` (`signal-ink`), bash `#3fb950`, pwsh `#5b9bff`; keep existing `surface`/`surface-2`/`border`/`text-primary`/`text-secondary`/`danger`/`accent` tokens for panels. Node cards 340px; dotted-grid canvas; dashed-amber `passes` chips on wires; kicker/field inspector.
- Frontend uses `@/lib/api` typed client, `@/components/ui`, react-query. Input styling follows the existing `inputClass` pattern in `web/app/secrets/page.tsx`.
---
## Task 1: Models + step CRUD + cascade delete
**Files:**
- Modify: `server/internal/models/workflow.go`
- Modify: `server/internal/services/workflows.go`
**Interfaces:**
- Produces: `models.InputParam{Name,Default,Description}`; `WorkflowStep.DeclaredInputs`, `WorkflowStepRef.Inputs`, `ResolvedStep.Inputs`. `DeleteStep(stepID)` now cascades to workflows.
- [ ] **Step 1: Add the model fields**
In `server/internal/models/workflow.go` add:
```go
type InputParam struct {
Name string `bson:"name" json:"name"`
Default string `bson:"default" json:"default"`
Description string `bson:"description" json:"description"`
}
```
- In `WorkflowStep`, add after `DeclaredOutputs`: `DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"``
- In `WorkflowStepRef`, add: `Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"``
- In `ResolvedStep`, add: `Inputs map[string]string `bson:"inputs" json:"inputs"``
- [ ] **Step 2: Persist declared_inputs in CreateStep/UpdateStep**
In `server/internal/services/workflows.go`:
- In `CreateStep`, after the `SecretRefs` nil-guard add:
```go
if s.DeclaredInputs == nil {
s.DeclaredInputs = []models.InputParam{}
}
```
- In `UpdateStep`'s `$set`, add: `"declared_inputs": s.DeclaredInputs,`
- [ ] **Step 3: Cascade DeleteStep to workflows**
Replace the body of `DeleteStep` with a version that also strips the step from every workflow:
```go
func DeleteStep(stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
return err
}
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
if err != nil {
return err
}
defer cur.Close(ctx)
var wfs []models.Workflow
if err := cur.All(ctx, &wfs); err != nil {
return err
}
for _, w := range wfs {
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
for _, ref := range w.Steps {
if ref.StepID == stepID {
continue
}
kept = append(kept, ref)
}
for i := range kept {
kept[i].Order = i
}
if _, err := db.Col("workflows").UpdateOne(ctx,
bson.M{"workflow_id": w.WorkflowID},
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
); err != nil {
return err
}
}
return nil
}
```
Confirm `models` and `time` are imported in this file (they are).
- [ ] **Step 4: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 5: Commit**
```bash
git add server/internal/models/workflow.go server/internal/services/workflows.go
git commit -m "feat(workflows): step input params model + cascade step delete"
```
---
## Task 2: Runner input injection + update handler returns workflow
**Files:**
- Modify: `server/internal/services/workflow_runner.go`
- Modify: `server/internal/api/workflows.go`
**Interfaces:**
- Consumes: `models.ResolvedStep.Inputs`, `WorkflowStep.DeclaredInputs`, `WorkflowStepRef.Inputs` (Task 1).
- Produces: runner injects input env; `PUT /api/workflows/:id` returns the `Workflow`.
- [ ] **Step 1: Resolve inputs in `resolveSteps`**
In `server/internal/services/workflow_runner.go`, in `resolveSteps`, after loading `lib` and before/where the `ResolvedStep` is built, compute the input env and set it. Add inside the loop (after `lib, err := getStep(...)` succeeds):
```go
inputs := map[string]string{}
for _, p := range lib.DeclaredInputs {
if ref.Inputs != nil {
if v, ok := ref.Inputs[p.Name]; ok {
inputs[p.Name] = v
continue
}
}
inputs[p.Name] = p.Default
}
```
and set `Inputs: inputs,` on the `models.ResolvedStep{...}` literal.
- [ ] **Step 2: Inject inputs into cmdEnv in `runServer`**
In `runServer`, where `cmdEnv` is built (currently: copy `runEnv`, then overlay `secretVals`), change the layering so inputs are the base layer:
```go
cmdEnv := map[string]string{}
for k, v := range step.Inputs {
cmdEnv[k] = v
}
for k, v := range runEnv {
cmdEnv[k] = v
}
for k, v := range secretVals {
cmdEnv[k] = v
}
```
(Inputs first = lowest precedence; upstream outputs override; secrets win. Keep the existing `secretVals`/`allSecrets` masking logic unchanged.)
- [ ] **Step 3: `updateWorkflow` handler returns the workflow**
In `server/internal/api/workflows.go`, change `updateWorkflow` so that after a successful `services.UpdateWorkflow`, it re-fetches and returns the workflow:
```go
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")
updated, err := services.GetWorkflow(c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
```
- [ ] **Step 4: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 5: Commit**
```bash
git add server/internal/services/workflow_runner.go server/internal/api/workflows.go
git commit -m "feat(workflows): inject step inputs into env; update returns workflow"
```
---
## Task 3: Frontend tokens, Modal primitive, API types
**Files:**
- Modify: `web/tailwind.config.ts`
- Create: `web/components/ui/Modal.tsx`
- Modify: `web/components/ui/index.ts`
- Modify: `web/lib/api.ts`
**Interfaces:**
- Produces: tokens `bash`/`pwsh`/`signal`/`signal-ink`; `<Modal open title onClose>children</Modal>`; TS `InputParam`, `WorkflowStep.declared_inputs`, `WorkflowStepRef.inputs`.
- [ ] **Step 1: Add tokens**
In `web/tailwind.config.ts`, add to `theme.extend.colors`:
```ts
bash: "#3fb950",
pwsh: "#5b9bff",
signal: "#f5a524",
"signal-ink": "#241800",
```
- [ ] **Step 2: Create the Modal primitive**
`web/components/ui/Modal.tsx`:
```tsx
"use client";
import { useEffect } from "react";
export function Modal({
open,
title,
onClose,
children,
wide,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
}
```
- [ ] **Step 3: Export it**
In `web/components/ui/index.ts` add: `export { Modal } from "./Modal";`
- [ ] **Step 4: API types**
In `web/lib/api.ts`:
- Add interface:
```ts
export interface InputParam {
name: string;
default: string;
description: string;
}
```
- In `WorkflowStep`, add `declared_inputs: InputParam[];`
- In `WorkflowStepRef`, add `inputs?: Record<string, string>;`
(`updateWorkflow` already typed to return `Workflow`; the backend now honors it.)
- [ ] **Step 5: Verify build**
Run: `cd web && npm run build`
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add web/tailwind.config.ts web/components/ui/Modal.tsx web/components/ui/index.ts web/lib/api.ts
git commit -m "feat(web): builder tokens, Modal primitive, input-param types"
```
---
## Task 4: Edit-base-step modal
**Files:**
- Create: `web/components/workflows/EditStepModal.tsx`
**Interfaces:**
- Consumes: `api.createStep`, `api.updateStep`, `api.deleteStep`, `Modal`, types (Task 3).
- Produces: `<EditStepModal open step onClose />` where `step` is a `WorkflowStep` (edit) or `null` (new).
- [ ] **Step 1: Write the modal**
Client component. Local form state seeded from `step` (or blank for new). Fields:
- **Name** (text), **Interpreter** (select bash/powershell), **Script** (`<textarea>` mono).
- **Outputs** (`declared_outputs`): a list of text chips with add/remove — an input + "Add" appends a name; each name shows an `✕` to remove.
- **Inputs** (`declared_inputs`): rows, each with `name` / `default` / `description` inputs and a remove button; an "Add input" button appends a blank `{name:"",default:"",description:""}`.
- Hint: "Reusable steps are shared across all workflows. Editing here changes it everywhere."
Actions:
- **Save**: build the `WorkflowStep` payload (`declared_outputs`, `declared_inputs`, `secret_refs: step?.secret_refs ?? []`, `description: ""` if absent). If editing (`step` truthy) call `api.updateStep(step.step_id, payload)`, else `api.createStep(payload)`. On success `queryClient.invalidateQueries({queryKey:["steps"]})` and `onClose()`.
- **Delete** (edit mode only): a confirm (`window.confirm`) then `api.deleteStep(step.step_id)`, invalidate `["steps"]` AND `["workflow"]` (broad — deleted step is pulled from workflows server-side), `onClose()`.
Use the `inputClass` styling pattern and `Button` variants (`primary` save, `danger` delete, `ghost` cancel). Follow existing token names. Filter empty input rows (blank `name`) out of the payload on save.
Skeleton:
```tsx
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, WorkflowStep, InputParam } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState(step?.name ?? "");
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
const [script, setScript] = useState(step?.script ?? "");
const [outputs, setOutputs] = useState<string[]>(step?.declared_outputs ?? []);
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
const [newOut, setNewOut] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// NOTE: because state is seeded from props, render the modal conditionally
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
const save = async () => {
setBusy(true); setError(null);
try {
const payload = {
name: name.trim(), description: step?.description ?? "", interpreter, script,
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
secret_refs: step?.secret_refs ?? [],
};
if (step) await api.updateStep(step.step_id, payload);
else await api.createStep(payload);
qc.invalidateQueries({ queryKey: ["steps"] });
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
const del = async () => {
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
setBusy(true); setError(null);
try {
await api.deleteStep(step.step_id);
qc.invalidateQueries({ queryKey: ["steps"] });
qc.invalidateQueries({ queryKey: ["workflow"] });
onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
return (
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
<div className="mb-2 flex flex-wrap gap-1">
{outputs.map((o) => (
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
{o}<button onClick={() => setOutputs(outputs.filter((x) => x !== o))}></button>
</span>
))}
</div>
<div className="flex gap-2">
<input className={inputClass} placeholder="OUTPUT_NAME" value={newOut} onChange={(e) => setNewOut(e.target.value)} />
<Button variant="ghost" size="sm" onClick={() => { if (newOut.trim()) { setOutputs([...outputs, newOut.trim()]); setNewOut(""); } }}>Add</Button>
</div>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
<div className="space-y-2">
{inputs.map((inp, i) => (
<div key={i} className="flex gap-2">
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}></Button>
</div>
))}
</div>
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
</div>
<div className="flex items-center justify-between pt-2">
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
</div>
</div>
</div>
</Modal>
);
}
```
**Important:** because the form state seeds from `step` props, the parent must either mount `EditStepModal` only while open, or pass a React `key={step?.step_id ?? "new"}` so the state re-initializes each time a different step is edited. Note this in the component with a comment; the builder (Task 6) will mount it with a `key`.
- [ ] **Step 2: Verify build**
Run: `cd web && npm run build`
Expected: success (the component may be unused until Task 6 — that's fine, but an unused import-free component builds).
- [ ] **Step 3: Commit**
```bash
git add web/components/workflows/EditStepModal.tsx
git commit -m "feat(web): edit-base-step modal with inputs/outputs editor"
```
---
## Task 5: Edit-workflow modal
**Files:**
- Create: `web/components/workflows/EditWorkflowModal.tsx`
**Interfaces:**
- Consumes: `api.updateWorkflow`, `api.deleteWorkflow`, `api.listServers`, `Modal`, `Workflow` (Task 3).
- Produces: `<EditWorkflowModal open workflow onSaved onClose />` — saves name/targets immediately, returns the updated workflow via `onSaved`.
- [ ] **Step 1: Write the modal**
Client component. Seeds name + selected target servers from `workflow`. Uses `api.listServers` (react-query) for the multiselect (checkbox chips keyed by `server_id`, labelled `hostname`). Actions:
- **Save**: `const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids })`; call `onSaved(updated)`; `onClose()`. (Backend now returns the workflow.)
- **Delete workflow**: confirm, `api.deleteWorkflow(workflow.workflow_id)`, then `router.push("/workflows")`.
```tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
import { Button, Modal } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
const router = useRouter();
const [name, setName] = useState(workflow.name);
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
const save = async () => {
setBusy(true); setError(null);
try {
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
onSaved(updated); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
const del = async () => {
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
setBusy(true); setError(null);
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
catch (e) { setError((e as Error).message); setBusy(false); }
};
return (
<Modal open={open} onClose={onClose} title="Edit workflow">
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
<div className="flex flex-wrap gap-2">
{servers?.map((s) => {
const on = targets.includes(s.server_id);
return (
<label key={s.server_id} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
{s.hostname}
</label>
);
})}
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
</div>
</div>
</div>
</Modal>
);
}
```
- [ ] **Step 2: Verify build**
Run: `cd web && npm run build`
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add web/components/workflows/EditWorkflowModal.tsx
git commit -m "feat(web): edit-workflow modal (name/targets/delete)"
```
---
## Task 6: Builder rewrite — mockup styling + drag-and-drop + inspector
**Files:**
- Modify: `web/app/workflows/[id]/page.tsx`
**Interfaces:**
- Consumes: `api.getWorkflow/updateWorkflow/listSteps/runWorkflow`, `EditStepModal` (Task 4), `EditWorkflowModal` (Task 5), tokens (Task 3).
Rebuild the page to the approved mockup. Reference values from the mockup:
- Canvas dotted grid: `background: radial-gradient(circle at 1px 1px, <border> 1px, transparent 0) 0 0 / 22px 22px` over `bg-background`.
- Node card: width `340px`, `rounded-[10px] border border-border bg-surface`, selected → `border-signal` + `ring-2 ring-signal/40`. Top row: index badge (mono, boxed), title, shell badge, then body with a mono script preview (`bg-surface-2 border border-border rounded p-2 text-xs`).
- Shell badges: bash → `text-bash bg-bash/15`, pwsh (powershell) → `text-pwsh bg-pwsh/15`, mono uppercase `text-[10px] px-1.5 py-0.5 rounded`.
- Wire: a 2px vertical `bg-border` segment ~26px tall between nodes.
- `passes` chip row: dashed amber pill `border border-dashed border-signal/55 bg-surface rounded-full px-3 py-1`, label "passes" (`text-[10px] uppercase text-text-secondary`), each output name a chip `bg-signal text-signal-ink font-mono text-[11px] rounded px-2 py-0.5`.
- Inspector fields: kicker (`text-[11px] uppercase tracking-wide text-text-secondary font-bold`), title with shell badge; each field block separated by `border-b border-border` with an uppercase label.
- [ ] **Step 1: Rewrite the builder page**
Requirements (keep everything in React state; **Save** persists via `api.updateWorkflow` and sets `wf` to the returned workflow):
1. **State/data:** load workflow (`["workflow", id]`), library (`["steps"]`), servers (`["servers"]`). Seed local `wf` from the query once. Keep `selected` (index in sorted order). `sortedSteps = [...wf.steps].sort(order)`.
2. **Topbar:** brand dot + `Workflows /` crumb + `wf.name` + `· draft`. Right: a **Targets** chip showing `${wf.target_server_ids.length} servers`; a **Runs** link (`<Link href={`/workflows/${id}/runs`}>`); an **Edit** button (opens `EditWorkflowModal`); **Save** (`variant="secondary"`, persists, `setWf(returned)`); **Run workflow** (amber: `className` using `bg-signal text-signal-ink`, or `variant="primary"` acceptable) → `api.runWorkflow` then route to the run detail.
3. **Library (left):** header "Step Library" + `+` button opening `EditStepModal` in new mode (`step={null}`). A search input filters `library` by name (case-insensitive). Group by interpreter with labels `Shared · Bash` / `Shared · PowerShell`. Each card: shell badge, name, `description`, a grip glyph (`⠿`), `cursor-grab`, and `draggable`. On `dragstart` set `dataTransfer` to `JSON.stringify({kind:"lib", stepId})`. Clicking the card still appends the step. A small edit affordance (e.g. a pencil `✎` button on hover) opens `EditStepModal` with that library step.
4. **Canvas (center):** dotted grid. For each `sortedSteps[i]`: render a drop target above it (a thin zone; on `drop` insert at `i`), then the node card. Node card is `draggable` (`dragstart` sets `{kind:"move", from:i}`). Between consecutive nodes render the wire + `passes` chips = union of `sortedSteps.slice(0,i)` `declared_outputs` (deduped). After the last node render an end drop zone styled `+ Drop a step here` (dashed). Handle `drop`:
- parse `dataTransfer`; if `kind==="lib"` insert a new `WorkflowStepRef{step_id, order:<pos>, on_failure:"stop", max_retries:0}` at the drop position; if `kind==="move"` move `from``pos`; then re-sequence all `order` to array index. Update `wf`.
- `dragover` must `e.preventDefault()` on drop zones to allow dropping.
- Selecting a node (click) sets `selected`.
5. **Inspector (right):** for the selected placement (`selectedRef` = `sortedSteps[selected]`, `selectedLib` = library by `step_id`):
- Kicker `Step ${selected+1} · Inspector`, title = shell badge + `selectedLib.name`.
- **Command** `<textarea>` bound to `selectedRef.overrides?.script ?? selectedLib.script`; edits write `overrides.script` on the ref (per-placement override). Hint about `$WORKFLOW_ENV`.
- **Inputs** (this step's `declared_inputs`): one row per param — label = `param.name` (+ description as sub-text), an input bound to `selectedRef.inputs?.[name] ?? ""` with `placeholder={param.default}`; edits write `ref.inputs[name]`.
- **Inputs · from upstream** (read-only): the union of prior steps' `declared_outputs` as `IN` rows.
- **Outputs · to $WORKFLOW_ENV** (read-only): this step's `declared_outputs` as `OUT` rows.
- **Secret refs**: keep the existing group/KEY checklist behavior (port it over) writing `overrides.secret_refs`.
- **On failure** select + **Max retries** (when retry).
- **Remove from workflow** button (danger) → remove the ref, re-sequence orders, clear selection.
6. **Modals:** render `<EditWorkflowModal>` (open state) and `<EditStepModal key={editingStep?.step_id ?? "new"} open step={editingStep} onClose>`; the library `+` and per-card edit set `editingStep`.
7. **Bug fix:** `save()` sets `wf` to the awaited `updateWorkflow` result (now a full workflow); no direct crash. Additionally guard: if the returned object lacks `steps`, keep the prior `wf` and surface an error.
Provide a complete, working implementation (this is a full rewrite of the file). Preserve the secret-group lazy-fetch (`api.getSecretGroup`) logic from the current file for the secret-refs checklist. Use `inputClass` styling. Do not leave TODOs.
- [ ] **Step 2: Verify build**
Run: `cd web && npm run build`
Expected: type-checks and builds. Manually confirm no `st.stdout`-style dead references and no unused imports.
- [ ] **Step 3: Commit**
```bash
git add web/app/workflows/[id]/page.tsx
git commit -m "feat(web): rebuild workflow builder — mockup styling, drag-and-drop, inputs inspector"
```
---
## Task 7: Runs list page + navigation links
**Files:**
- Create: `web/app/workflows/[id]/runs/page.tsx`
- Modify: `web/app/workflows/page.tsx`
**Interfaces:**
- Consumes: `api.listRuns`, `api.getWorkflow`.
- [ ] **Step 1: Runs list page**
`web/app/workflows/[id]/runs/page.tsx` — client component. Load `api.getWorkflow(id)` (for the name) and `api.listRuns(id)`. Render a header "Runs · <name>" with a "← Back to builder" `Link` to `/workflows/${id}`, and a table (`@/components/ui` Table) of runs: short run id, a status `Badge` (map success→success, failed→danger, running→accent/warning, cancelled→neutral), `started_at` (localized), `triggered_by`, and server count (`server_runs.length`). Each row links to `/workflows/${id}/runs/${run.run_id}`. Empty state "No runs yet."
Use the existing Badge variants (success/warning/danger/neutral/accent) — verify names in `web/components/ui/Badge.tsx`.
Skeleton:
```tsx
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, WorkflowRun } from "@/lib/api";
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
const statusVariant: Record<string, string> = {
success: "success", failed: "danger", running: "warning", cancelled: "neutral", queued: "neutral",
};
export default function WorkflowRunsPage() {
const { id } = useParams<{ id: string }>();
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
const { data: runs } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
return (
<div className="p-8">
<div className="mb-6">
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary"> Back to builder</Link>
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
</div>
<Card padding={false}>
{runs && runs.length > 0 ? (
<Table>
<Thead><Tr><Th>Run</Th><Th>Status</Th><Th>Started</Th><Th>By</Th><Th>Servers</Th></Tr></Thead>
<Tbody>
{runs.map((r: WorkflowRun) => (
<Tr key={r.run_id}>
<Td><Link href={`/workflows/${id}/runs/${r.run_id}`} className="font-mono text-text-primary hover:text-signal">{r.run_id.slice(0, 8)}</Link></Td>
<Td><Badge variant={(statusVariant[r.status] ?? "neutral") as never}>{r.status}</Badge></Td>
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
<Td className="text-text-secondary">{r.triggered_by}</Td>
<Td className="text-text-secondary">{r.server_runs.length}</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
)}
</Card>
</div>
);
}
```
Adapt `Card padding={false}` / Badge variant prop to the real component signatures (check `Card.tsx`/`Badge.tsx`).
- [ ] **Step 2: Add a Runs link on the workflows list**
In `web/app/workflows/page.tsx`, in each workflow row add a "Runs" link/button next to "Open" → `Link href={`/workflows/${w.workflow_id}/runs`}`. Match the existing row action styling.
- [ ] **Step 3: Verify build**
Run: `cd web && npm run build`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add web/app/workflows/[id]/runs/page.tsx web/app/workflows/page.tsx
git commit -m "feat(web): workflow runs list page and navigation links"
```
---
## Task 8: End-to-end verification
**Files:** none (verification only).
- [ ] **Step 1: Build everything**
Run: `cd server && go build ./... && go vet ./... && cd ../web && npm run build`
Expected: all succeed.
- [ ] **Step 2: Manual smoke (documented, run if an environment is available)**
1. Open a workflow builder; confirm it matches the mockup (dotted canvas, node cards, amber passes chips, kicker/field inspector).
2. Drag a library step onto the canvas; drag to reorder; confirm order persists after Save (no "steps is not iterable" crash).
3. Open Edit base step; add an input param (name/default/description) and an output; Save. Place the step; set the input value in the inspector; Run; confirm the script sees the input env var and downstream steps see the output.
4. Delete a shared step from the Edit-base-step modal; confirm it disappears from every workflow that used it.
5. Open Edit workflow; rename, change target servers, Save; confirm persisted. Delete a throwaway workflow; confirm redirect to `/workflows`.
6. From the workflows list and the builder topbar, navigate to Runs; open a run.
- [ ] **Step 3: Commit any fixes found**
```bash
git add -A
git commit -m "fix: workflow builder v2 e2e fixes"
```
---
## Self-Review Notes
- **Spec coverage:** §3 model → T1; §4.1 cascade/CRUD → T1; §4.2 update-returns-workflow → T2; §4.3 runner inputs → T2; §6.1 tokens/Modal → T3; §6.2 api types → T3; §6.4 edit-step modal → T4; §6.5 edit-workflow modal → T5; §6.3 builder restyle/DnD/inspector → T6; §6.6 runs page + nav → T7. Save crash fixed by T2 (server) + T6 (client guard). Tests omitted per Global Constraints.
- **Dependency order:** modals (T4, T5) land before the builder (T6) that imports them; tokens/Modal/api (T3) first.
- **Interpreter literals** `"bash"`/`"powershell"` consistent across T1/T4/T6.
- **Modal state seeding:** EditStepModal re-seeds via `key` in the builder (documented in T4/T6).
- **Open follow-ups (out of scope):** typed/required inputs, drag-to-trash removal, live status in canvas, keyboard reordering.
```
@@ -1,887 +0,0 @@
# Workflow Log Streaming 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:** Stream workflow step output live from agents to per-server-run log files on the server, tail them live in the UI over SSE, and auto-expire them on a configurable retention period.
**Architecture:** Agent streams interleaved stdout/stderr chunks over the existing `CommandStream` (`AgentMessage.StepOutput`). Server appends secret-masked chunks to `<logdir>/<run_id>/<server_id>.log` via a per-command log-writer registry, records a per-step byte offset, and stops persisting log bodies in Mongo. UI tails via an SSE endpoint while running and fetches the whole file after. An hourly sweeper deletes run-log dirs older than the retention setting.
**Tech Stack:** Go (gin, mongo-driver v2), hand-written JSON-codec gRPC structs (no protoc), Next.js 16 app-router + react-query + EventSource, MongoDB, local filesystem for logs.
## 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`. No codegen. Also update `proto/vantage/v1/vantage.proto` as documentation.
- Mongo access pattern: `db.Col("collection_name")` with `context.WithTimeout`. Follow `server/internal/services/workflows.go`.
- Secret values must never be written into log files unmasked — mask by literal `***` replacement at write time, boundary-safe via a carry buffer.
- Interpreter values are the literals `"bash"` and `"powershell"`.
- Go module path: `github.com/mrhid6/vantage`.
- Log dir from env `VANTAGE_WORKFLOW_LOG_DIR`, default `<data>/workflow-logs`; files `0600`, dirs `0700`.
- Retention default **30** days, stored `settings.workflow_log_retention_days`; `0`/negative = keep forever.
- The agent's stream `Send` is only safe through the existing per-connection mutex-guarded `send()` closure in `connectAndHandleStream` — all `StepOutput`/`StepResult` sends MUST go through it.
---
## Task 1: Proto/pb — StepOutputChunk
**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.StepOutputChunk{CommandId string, Seq uint64, Data []byte, Eof bool}`; `pb.AgentMessage` gains `StepOutput *StepOutputChunk`.
- [ ] **Step 1: Document in the proto file**
In `proto/vantage/v1/vantage.proto`, add to the `AgentMessage` oneof: `StepOutputChunk step_output = 6;` and add the message:
```protobuf
message StepOutputChunk {
string command_id = 1;
uint64 seq = 2;
bytes data = 3;
bool eof = 4;
}
```
- [ ] **Step 2: Add struct + field to server pb file**
In `server/internal/grpc/pb/vantage.pb.go`, add to `type AgentMessage struct { ... }`:
```go
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
```
and add the new struct:
```go
type StepOutputChunk struct {
CommandId string `json:"command_id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data,omitempty"`
Eof bool `json:"eof,omitempty"`
}
```
- [ ] **Step 3: Mirror identical additions into the agent pb file**
Apply the identical `AgentMessage.StepOutput` field and `StepOutputChunk` struct to `agent/internal/grpc/pb/vantage.pb.go`.
- [ ] **Step 4: Verify build**
Run: `cd server && go build ./... && cd ../agent && go build ./...`
Expected: both succeed.
- [ ] **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 StepOutputChunk streaming message"
```
---
## Task 2: Agent — stream step output
**Files:**
- Modify: `agent/internal/exec/exec.go`
- Modify: `agent/internal/sync/sync.go` (the `cmd.RunStep != nil` goroutine)
**Interfaces:**
- Consumes: `pb.RunStepCmd`, `pb.StepResult`, `pb.StepOutputChunk` (Task 1).
- Produces: `exec.RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult` — streams output via `emit`, returns terminal result with empty stdout/stderr but populated exit_code/output_env.
- [ ] **Step 1: Rework `exec.RunStep` to stream**
In `agent/internal/exec/exec.go`, change the signature and replace the two `bytes.Buffer`s with a single mutex-guarded streaming writer. Full new body of the run/capture section (keep the existing temp-dir, env-file, interpreter-selection, timeout, and `parseEnvFile` logic exactly as-is):
Add this type at package scope:
```go
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
// Stdout and Stderr so output interleaves in real execution order. The mutex
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
type streamWriter struct {
mu sync.Mutex
seq uint64
emit func(seq uint64, data []byte)
}
func (w *streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.emit != nil {
buf := make([]byte, len(p))
copy(buf, p)
w.emit(w.seq, buf)
w.seq++
}
return len(p), nil
}
```
Add `"sync"` to the imports. Change the signature to:
```go
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
```
Replace the block that currently declares `var stdout, stderr bytes.Buffer`, assigns `c.Stdout`/`c.Stderr`, and sets `res.Stdout`/`res.Stderr` from them, with:
```go
sw := &streamWriter{emit: emit}
c.Stdout = sw
c.Stderr = sw
runErr := c.Run()
// stdout/stderr are streamed via emit, not returned in the result.
if ctx.Err() == context.DeadlineExceeded {
res.ExitCode = 124
res.Stderr = "[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 = "[vantage] " + runErr.Error()
}
res.OutputEnv = parseEnvFile(envFile)
return res
```
Remove the now-unused `"bytes"` and `"bufio"` imports **only if** they are no longer referenced (`parseEnvFile` uses `bufio` + `os` — keep `bufio`; `bytes` is likely now unused — remove it if so). Verify with `go build`.
- [ ] **Step 2: Wire streaming into the agent loop**
In `agent/internal/sync/sync.go`, the `cmd.RunStep != nil` goroutine currently calls `agentexec.RunStep(rc)` and sends one `StepResult` via `send()`. Change it to pass an `emit` closure that streams chunks, then send an eof chunk, then the terminal result — all through the existing mutex-guarded `send()`:
```go
if cmd.RunStep != nil {
go func(rc *pb.RunStepCmd, cid string) {
emit := func(seq uint64, data []byte) {
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
StepOutput: &pb.StepOutputChunk{CommandId: cid, Seq: seq, Data: data},
})
}
res := agentexec.RunStep(rc, emit)
res.CommandId = cid
// Final eof marker so the server closes the log file.
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
StepOutput: &pb.StepOutputChunk{CommandId: cid, Eof: true},
})
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
StepResult: res,
})
}(cmd.RunStep, cmd.CommandId)
continue
}
```
(Match the exact field names already used by the existing `send()` calls in this function — `cfg.ServerID`, `cfg.AgentToken`, and the `send` closure. If the existing RunStep branch used different local names, keep those.)
- [ ] **Step 3: Verify build**
Run: `cd agent && go build ./... && go vet ./...`
Expected: success. Resolve any leftover unused-import error from Step 1.
- [ ] **Step 4: Commit**
```bash
git add agent/internal/exec/exec.go agent/internal/sync/sync.go
git commit -m "feat(agent): stream step output chunks over CommandStream"
```
---
## Task 3: Server log-writer registry + retention sweeper
**Files:**
- Create: `server/internal/services/steplogs.go`
**Interfaces:**
- Consumes: `settings` service (retention), `db.Col("workflow_runs")` (sweeper), env `VANTAGE_WORKFLOW_LOG_DIR`.
- Produces:
- `WorkflowLogDir() string` — resolved base dir (env or default), created on first call.
- `ServerRunLogPath(runID, serverID string) string``<logdir>/<runID>/<serverID>.log`.
- `AppendMarker(runID, serverID, line string) (int64, error)` — appends a marker line, returns the byte offset **before** the write (the step's `log_offset`).
- `var StepLogs *stepLogRegistry` with `Open(commandID, path string, secrets []string) error`, `Append(commandID string, data []byte)`, `Close(commandID string)`.
- `StartLogSweeper()` — launches the hourly retention goroutine; also sweeps once immediately.
- [ ] **Step 1: Write the registry, paths, and sweeper**
```go
package services
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
)
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
func WorkflowLogDir() string {
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
if dir == "" {
dir = filepath.Join("data", "workflow-logs")
}
_ = os.MkdirAll(dir, 0700)
return dir
}
// ServerRunLogPath is the per-server-run log file path.
func ServerRunLogPath(runID, serverID string) string {
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
}
// AppendMarker appends a line to the server-run log and returns the byte offset
// at which the write began (used as a step's log_offset).
func AppendMarker(runID, serverID, line string) (int64, error) {
path := ServerRunLogPath(runID, serverID)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return 0, err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return 0, err
}
defer f.Close()
off, _ := f.Seek(0, 2) // current end = offset before write
if _, err := f.WriteString(line); err != nil {
return off, err
}
return off, nil
}
// ---- streamed chunk writer, boundary-safe secret masking ----
type stepLogWriter struct {
mu sync.Mutex
f *os.File
carry []byte
secrets []string
maxSecret int
}
type stepLogRegistry struct {
mu sync.Mutex
writers map[string]*stepLogWriter
}
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
// Open opens (append) the server-run file for a step's streamed chunks.
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
max := 0
for _, s := range secrets {
if len(s) > max {
max = len(s)
}
}
w := &stepLogWriter{f: f, secrets: secrets, maxSecret: max}
r.mu.Lock()
r.writers[commandID] = w
r.mu.Unlock()
return nil
}
func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
r.mu.Lock()
defer r.mu.Unlock()
return r.writers[commandID]
}
// Append masks and writes a chunk, holding back the last maxSecret-1 bytes so a
// secret split across a chunk boundary is still masked on the next append/close.
func (r *stepLogRegistry) Append(commandID string, data []byte) {
w := r.get(commandID)
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if len(w.secrets) == 0 || w.maxSecret <= 1 {
_, _ = w.f.Write(data)
return
}
buf := append(w.carry, data...)
hold := w.maxSecret - 1
if len(buf) <= hold {
w.carry = buf
return
}
flush := buf[:len(buf)-hold]
w.carry = append([]byte{}, buf[len(buf)-hold:]...)
_, _ = w.f.Write(maskBytes(flush, w.secrets))
}
// Close flushes the carry (masked) and closes the file.
func (r *stepLogRegistry) Close(commandID string) {
r.mu.Lock()
w := r.writers[commandID]
delete(r.writers, commandID)
r.mu.Unlock()
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if len(w.carry) > 0 {
_, _ = w.f.Write(maskBytes(w.carry, w.secrets))
w.carry = nil
}
_ = w.f.Close()
}
func maskBytes(b []byte, secrets []string) []byte {
s := string(b)
for _, v := range secrets {
if v == "" {
continue
}
s = strings.ReplaceAll(s, v, "***")
}
return []byte(s)
}
// ---- retention sweeper ----
// StartLogSweeper sweeps expired run-log dirs hourly (and once now).
func StartLogSweeper() {
go func() {
sweepLogs()
t := time.NewTicker(time.Hour)
defer t.Stop()
for range t.C {
sweepLogs()
}
}()
}
func sweepLogs() {
days := retentionDays()
if days <= 0 {
return
}
cutoff := time.Now().AddDate(0, 0, -days)
base := WorkflowLogDir()
entries, err := os.ReadDir(base)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
runID := e.Name()
dir := filepath.Join(base, runID)
if runExpired(runID, dir, cutoff) {
_ = os.RemoveAll(dir)
}
}
}
// runExpired is true when the run finished before cutoff (falling back to dir
// mtime when the run doc is gone).
func runExpired(runID, dir string, cutoff time.Time) bool {
ctx, cancel := wfCtx()
defer cancel()
var run struct {
FinishedAt *time.Time `bson:"finished_at"`
}
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
if err == nil {
if run.FinishedAt == nil {
return false // still running / never finished — keep
}
return run.FinishedAt.Before(cutoff)
}
// run doc gone: use dir mtime
if fi, e := os.Stat(dir); e == nil {
return fi.ModTime().Before(cutoff)
}
return false
}
func retentionDays() int {
if v, err := GetWorkflowLogRetentionDays(); err == nil {
return v
}
return 30
}
```
Note: `wfCtx` is defined in `workflows.go` (same package) — reuse it. `GetWorkflowLogRetentionDays` is added in Task 4; this file references it (same package, compiles together).
- [ ] **Step 2: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: FAIL — `GetWorkflowLogRetentionDays` undefined until Task 4. This is expected; proceed to commit the file so Task 4 completes it. (If you prefer a green build, do Task 4's settings accessor first, then return — but committing here is fine since Task 4 immediately follows.)
Actually to keep every commit buildable: **temporarily** add a local stub at the bottom of this file and remove it in Task 4:
```go
// TEMP stub, replaced in Task 4.
func GetWorkflowLogRetentionDays() (int, error) { return 30, nil }
```
Then `cd server && go build ./... && go vet ./...` must succeed.
- [ ] **Step 3: Commit**
```bash
git add server/internal/services/steplogs.go
git commit -m "feat(server): workflow log-writer registry, paths, retention sweeper"
```
---
## Task 4: Settings — retention accessor + startup wiring
**Files:**
- Modify: `server/internal/services/settings.go` (or wherever settings get/set lives — search `settings` collection usage)
- Modify: `server/internal/services/steplogs.go` (remove the temp stub)
- Modify: `server/cmd/main.go` (start the sweeper)
**Interfaces:**
- Produces: `GetWorkflowLogRetentionDays() (int, error)` (default 30 when unset), `SetWorkflowLogRetentionDays(int) error`. If settings are exposed as a single document/struct, add the field there and derive these accessors.
- [ ] **Step 1: Inspect the settings service**
Read the existing settings service (search for the `settings` collection: `grep -rn "\"settings\"" server/internal/services`). Determine whether settings are a typed struct document or key/value. Match that pattern.
- [ ] **Step 2: Add the retention accessor**
If settings are a **typed document** (e.g. a `GetSettings()/UpdateSettings()`), add a field `WorkflowLogRetentionDays int `bson:"workflow_log_retention_days" json:"workflow_log_retention_days"`` to the settings struct and implement:
```go
func GetWorkflowLogRetentionDays() (int, error) {
s, err := GetSettings() // use the real accessor name
if err != nil {
return 30, err
}
if s.WorkflowLogRetentionDays == 0 && /* unset sentinel */ !s.WorkflowLogRetentionSet {
return 30, nil
}
return s.WorkflowLogRetentionDays, nil
}
```
Simplify to match reality: if the settings doc uses zero-value-means-unset and you cannot distinguish "0 = keep forever" from "unset", store the retention as a pointer `*int` or default at read: **treat a missing field as 30, an explicit 0 as keep-forever.** Prefer `*int` in the struct so the three states (unset→30, 0→forever, N→N) are representable. Implement `GetWorkflowLogRetentionDays` to return 30 when the pointer is nil, else its value. `SetWorkflowLogRetentionDays(n int)` sets the pointer.
If settings are **key/value**, implement both accessors against that store with the same nil→30 / 0→forever semantics (store empty/absent = 30).
- [ ] **Step 3: Remove the temp stub from `steplogs.go`**
Delete the `// TEMP stub` `GetWorkflowLogRetentionDays` added in Task 3 so the real one is used.
- [ ] **Step 4: Start the sweeper at boot**
In `server/cmd/main.go`, next to `EnsureWorkflowIndexes()`, add `services.StartLogSweeper()`.
- [ ] **Step 5: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success (real accessor now resolves the reference from Task 3).
- [ ] **Step 6: Commit**
```bash
git add server/internal/services/settings.go server/internal/services/steplogs.go server/cmd/main.go
git commit -m "feat(server): workflow log retention setting + sweeper startup"
```
---
## Task 5: Runner + model — write to files, drop log bodies from Mongo
**Files:**
- Modify: `server/internal/models/workflow.go` (`StepRun`)
- Modify: `server/internal/services/workflow_runner.go`
- Modify: `server/internal/grpc/server.go` (stream delivery of `StepOutput`)
**Interfaces:**
- Consumes: `StepLogs`, `AppendMarker`, `ServerRunLogPath` (Task 3), `pb.StepOutputChunk` (Task 1).
- Produces: runner writes markers + streams chunks to files; `StepRun.LogOffset` persisted; `StepRun.Stdout/Stderr` removed.
- [ ] **Step 1: Update the `StepRun` model**
In `server/internal/models/workflow.go`, in `type StepRun struct`:
- Remove the `Stdout` and `Stderr` fields.
- Add: `LogOffset int64 `bson:"log_offset" json:"log_offset"``
- [ ] **Step 2: Deliver StepOutput chunks in the gRPC receive loop**
In `server/internal/grpc/server.go`, after the existing `if m.StepResult != nil { services.StepResults.Deliver(m.StepResult) }` block, add:
```go
if m.StepOutput != nil {
if m.StepOutput.Eof {
services.StepLogs.Close(m.StepOutput.CommandId)
} else {
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
}
}
```
- [ ] **Step 3: Rework `runServer` to open logs + write markers, drop persisted bodies**
In `server/internal/services/workflow_runner.go`, `runServer`:
Inside the per-step loop, **before** `dispatchAndWait`, add marker + open (compute `secretVals` first, which already exists in the loop):
```go
// Write the step marker and remember the offset for later slicing.
marker := fmt.Sprintf("\n===== step %d: %s =====\n", step.Order, step.Name)
offset, _ := AppendMarker(runID, serverID, marker)
logPath := ServerRunLogPath(runID, serverID)
_ = StepLogs.Open(commandID_placeholder, logPath, secretsSlice(secretVals))
```
There is a chicken-and-egg with `commandID`: today `dispatchAndWait` generates the `commandID` internally. Refactor so the runner owns the `commandID`:
1. Change `dispatchAndWait(serverID string, cmd *pb.RunStepCmd)` to `dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd)` and remove its internal `commandID := uuid.New().String()` (use the passed one).
2. In `runServer`, generate `commandID := uuid.New().String()` at the top of each attempt-group (before the marker/open), open the log with it, then call `dispatchAndWait(serverID, commandID, cmd)`.
3. After the step completes (result received), call `StepLogs.Close(commandID)` defensively (idempotent — the agent's eof usually closed it already; Close on a missing key is a no-op).
Add a helper to convert the `secretVals map[string]string` to a `[]string` of values:
```go
func secretsSlice(m map[string]string) []string {
out := make([]string, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
```
Update `finishStep(...)` call + signature: **remove** the `stdout, stderr string` params and the `output_env` masking stays. Persist `log_offset` instead. New `finishStep`:
```go
func finishStep(runID, serverID string, order int, status string, attempts, exit int, logOffset int64, 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].log_offset": logOffset,
"server_runs.$[s].steps.$[t].output_env": outEnv,
"server_runs.$[s].steps.$[t].finished_at": now,
})
}
```
In the loop, after receiving `res`, drop the `stdout, stderr := ...` masking of `res.Stdout/res.Stderr` (those are now streamed to file). Keep the `outEnv` build **with existing masking** (`maskSecrets(v, allSecrets)` per the merged secret-leak fix) — `output_env`/`run_env` masking is unchanged. Call:
```go
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
```
where `offset` is the marker offset captured before dispatch. If `res == nil`, still write a short note to the file so failures are visible:
```go
if res == nil {
_, _ = AppendMarker(runID, serverID, "[vantage] agent did not return a result\n")
}
```
Remove the initial `StepRun{... Status:"queued"}` `Stdout/Stderr` references if any (the model no longer has them — the queued StepRun in `TriggerWorkflow` set only `Order/Name/Status/OutputEnv`, so no change needed there; verify).
Ensure `fmt` is imported (it already is).
- [ ] **Step 4: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success. Fix any remaining references to the removed `Stdout`/`Stderr` fields or the old `finishStep`/`dispatchAndWait` signatures.
- [ ] **Step 5: Commit**
```bash
git add server/internal/models/workflow.go server/internal/services/workflow_runner.go server/internal/grpc/server.go
git commit -m "feat(server): stream step logs to files, drop log bodies from run docs"
```
---
## Task 6: REST — log fetch + SSE stream endpoints
**Files:**
- Modify: `server/internal/api/workflows.go`
**Interfaces:**
- Consumes: `ServerRunLogPath`, `GetRun` (existing).
- Produces: `GET /api/runs/:runId/servers/:serverId/logs` and `GET /api/runs/:runId/servers/:serverId/logs/stream` (SSE).
- [ ] **Step 1: Add the two handlers + routes**
In `registerWorkflowRoutes`, add:
```go
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
```
Add a UUID-ish validator and the handlers:
```go
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
func getServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
path := services.ServerRunLogPath(runID, serverID)
b, err := os.ReadFile(path)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
return
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", b)
}
func streamServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
path := services.ServerRunLogPath(runID, serverID)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
return
}
var offset int64
sendNew := func() bool {
f, err := os.Open(path)
if err != nil {
return true // file may not exist yet; keep waiting
}
defer f.Close()
if _, err := f.Seek(offset, 0); err != nil {
return true
}
buf := make([]byte, 32*1024)
for {
n, _ := f.Read(buf)
if n <= 0 {
break
}
offset += int64(n)
// SSE data frame; split on newlines to keep frames well-formed.
for _, line := range splitSSE(buf[:n]) {
_, _ = c.Writer.WriteString("data: " + line + "\n")
}
_, _ = c.Writer.WriteString("\n")
flusher.Flush()
}
return true
}
ctx := c.Request.Context()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
sendNew()
if serverRunTerminal(runID, serverID) {
sendNew() // final drain
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
flusher.Flush()
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// serverRunTerminal reports whether the given server-run has reached a terminal status.
func serverRunTerminal(runID, serverID string) bool {
r, err := services.GetRun(runID)
if err != nil {
return true
}
for _, sr := range r.ServerRuns {
if sr.ServerID == serverID {
switch sr.Status {
case "success", "failed", "skipped", "cancelled":
return true
}
return false
}
}
return true
}
// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become
// separate data lines; carriage returns stripped).
func splitSSE(b []byte) []string {
s := strings.ReplaceAll(string(b), "\r", "")
return strings.Split(s, "\n")
}
```
Add imports: `"os"`, `"regexp"`, `"strings"`, `"time"`, `"net/http"` (already present). Confirm `services.GetRun` and `ServerRun.Status`/`ServerID` fields exist (they do from the Workflows feature).
- [ ] **Step 2: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add server/internal/api/workflows.go
git commit -m "feat(api): server-run log fetch and SSE stream endpoints"
```
---
## Task 7: Frontend — live SSE tail + retention setting
**Files:**
- Modify: `web/lib/api.ts`
- Modify: `web/app/workflows/[id]/runs/[runId]/page.tsx`
- Modify: `web/app/settings/page.tsx`
**Interfaces:**
- Consumes: SSE endpoint, logs endpoint, settings mutation.
- [ ] **Step 1: Update API types + helpers**
In `web/lib/api.ts`:
- In `StepRun`, remove `stdout` and `stderr`; add `log_offset: number`.
- Add: `getServerRunLog: (runId: string, serverId: string) => request<string>(...)` — but the logs endpoint returns `text/plain`, so add a dedicated fetch that reads text. If `request<T>` assumes JSON, add a sibling:
```ts
async getServerRunLog(runId: string, serverId: string): Promise<string> {
const res = await fetch(`${API_BASE}/api/runs/${runId}/servers/${serverId}/logs`, { credentials: "include" });
if (!res.ok) throw new Error("no logs");
return res.text();
},
```
(Use the file's real base-URL constant / credentials pattern — inspect how `request` builds URLs and mirror it. If the app is same-origin with a rewrite, a relative `/api/...` fetch is fine.)
- Export a helper to build the SSE URL: `serverRunLogStreamUrl(runId, serverId)` returning the `/api/runs/:runId/servers/:serverId/logs/stream` URL against the same base.
- In the Settings type, add `workflow_log_retention_days?: number | null`.
- [ ] **Step 2: Live tail in the run detail page**
In `web/app/workflows/[id]/runs/[runId]/page.tsx`:
- Remove all use of `st.stdout` / `st.stderr` (fields gone). Step `<details>` now show status/exit/attempts pills only.
- Add a per-server live terminal. For each `server_run`, render a `<pre>` and, while `sr.status === "running"`, subscribe via `EventSource`:
```tsx
function ServerLog({ runId, serverId, status }: { runId: string; serverId: string; status: string }) {
const [text, setText] = useState("");
const preRef = useRef<HTMLPreElement>(null);
const running = status === "running";
useEffect(() => {
if (running) {
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), { withCredentials: true });
es.onmessage = (e) => setText((t) => t + e.data + "\n");
es.addEventListener("done", () => es.close());
es.onerror = () => es.close();
return () => es.close();
}
// terminal: fetch the whole file once
api.getServerRunLog(runId, serverId).then(setText).catch(() => setText(""));
}, [running, runId, serverId]);
useEffect(() => { preRef.current?.scrollTo(0, preRef.current.scrollHeight); }, [text]);
return (
<pre ref={preRef} className="mt-2 max-h-80 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary whitespace-pre-wrap">
{text || (running ? "Waiting for output…" : "No output.")}
</pre>
);
}
```
Render `<ServerLog runId={run.run_id} serverId={sr.server_id} status={sr.status} />` inside each server card, below the step pills. Keep the existing react-query `refetchInterval` on the run (drives status pills); the SSE handles live text.
- [ ] **Step 3: Retention field in Settings**
In `web/app/settings/page.tsx`, add a "Workflow log retention (days)" number input bound to `workflow_log_retention_days`, saved through the existing settings save mutation. Add helper text: "0 = keep forever." Match the page's existing input styling.
- [ ] **Step 4: Verify build**
Run: `cd web && npm run build`
Expected: type-checks and builds. Fix any lingering `st.stdout`/`st.stderr` references.
- [ ] **Step 5: Commit**
```bash
git add web/lib/api.ts web/app/workflows/[id]/runs/[runId]/page.tsx web/app/settings/page.tsx
git commit -m "feat(web): live SSE log tail and log retention setting"
```
---
## Task 8: End-to-end verification
**Files:** none (verification only).
- [ ] **Step 1: Build everything**
Run: `cd server && go build ./... && go vet ./... && cd ../agent && go build ./... && go vet ./... && cd ../web && npm run build`
Expected: all succeed.
- [ ] **Step 2: Manual smoke (documented, run if an environment is available)**
With server + MongoDB + a connected agent:
1. Run a workflow with a step that emits output slowly (e.g. `for i in $(seq 1 10); do echo "line $i"; sleep 1; done`). Open the run detail page while running; confirm lines appear live (SSE), not only at the end.
2. Confirm `<logdir>/<run_id>/<server_id>.log` exists on the server with step markers and the output.
3. Confirm `workflow_runs` doc no longer stores stdout/stderr bodies; `steps[].log_offset` is set.
4. Add a secret ref and echo it; confirm the file shows `***`, including when the secret would straddle a chunk boundary.
5. Set retention to 0 in Settings → confirm sweeper keeps files; set to a small value and backdate a run's `finished_at` → confirm the dir is removed within the hour (or call `sweepLogs` path manually).
- [ ] **Step 3: Commit any fixes found**
```bash
git add -A
git commit -m "fix: workflow log streaming e2e fixes"
```
---
## Self-Review Notes
- **Spec coverage:** §3 proto → T1; §4 agent streaming → T2; §5.1 registry + §7 sweeper → T3; §7.1 setting + startup → T4; §5.3/§5.4 runner+model → T5; §6 REST/SSE → T6; §8 frontend → T7. Tests omitted per Global Constraints.
- **Masking** boundary-safe carry buffer in `StepLogs.Append`, flushed in `Close` (T3); `output_env`/`run_env` masking unchanged (T5 keeps the merged fix).
- **commandID ownership** moved to the runner so the log file can be opened before dispatch (T5) — mirrors the `StepResults.Await`-before-dispatch ordering.
- **Buildable commits:** T3 adds a temp stub for `GetWorkflowLogRetentionDays`, removed in T4.
- **Removed fields** `StepRun.Stdout/Stderr` — every reader updated in T5 (runner) and T7 (frontend).
- **Open follow-ups (out of scope):** per-step SSE channels, log download/zip, compression, pre-existing runs have no files.
```
@@ -1,241 +0,0 @@
# Vantage Web Console (Guacamole Replacement) — Design
**Date:** 2026-07-17
**Status:** Approved design, pre-implementation
## Goal
Add a browser-based remote-access console to Vantage — SSH, RDP, and VNC into
managed servers — as a self-hosted Guacamole replacement. Users select an SSH
key to connect over SSH. RDP targets are reachable from a new Windows agent that
registers the host and reports status. Windows agent ships as an MSI installer
produced by CI.
## Non-Goals (YAGNI)
- Session recording / replay (may be added later).
- Native Go RDP implementation (guacd handles protocol translation).
- Per-user Linux/Windows account management from the agent.
- Tunneling console traffic through the agent (direct network path assumed).
---
## Architecture
```
Browser (guacamole-common-js, vendored — no CDN)
│ Guacamole protocol over WebSocket
Go server: /api/console/tunnel (github.com/wwt/guac)
│ Guacamole protocol over TCP :4822
guacd container (Apache Guacamole daemon)
│ SSH :22 / RDP :3389 / VNC :5900 — direct to target IP
Target host (LAN / VPN line-of-sight from server)
```
- **Browser:** loads vendored `guacamole-common-js`, renders RDP/VNC display and
SSH terminal. No external CDN (matches existing infra rules).
- **Go server:** exposes a WebSocket tunnel endpoint using `github.com/wwt/guac`
(Go Guacamole tunnel library). No Java `guacamole-client` required.
- **guacd:** new container in `deploy/docker-compose.yml`, bound to the internal
docker network only, reachable by the server on `:4822`.
- **Network path:** guacd connects **directly** to the target IP. Requires the
central server to have network line-of-sight to hosts (homelab LAN / VPN). The
agent's outbound-only guarantee is unchanged — the console path is
server→target, not agent-mediated.
---
## Data Model Changes
### `keys` — extend to hold private material
```json
{
"key_id": "uuid",
"label": "dom-macbook",
"public_key": "ssh-ed25519 AAAA...",
"private_key_enc": "<AES-256-GCM ciphertext | null>",
"has_private": true,
"passphrase_enc": "<AES-256-GCM ciphertext | null>",
"fingerprint": "SHA256:...",
"source": "uploaded|generated",
"created_at": "ISODate"
}
```
- A key may be created from an uploaded **private+public** pair, upload of a
public key only, or agent generation.
- Agent key generation now also uploads `private_key_enc` (reuses the existing
AES-256 key used for at-rest encryption). Private key no longer stays local
only — it is stored encrypted so the console can reuse it.
- Optional `passphrase_enc` for passphrase-protected private keys.
- Console lists only keys where `has_private = true`.
### `servers` — extend with console metadata
```json
{
"...": "...existing fields...",
"os_type": "linux|windows",
"console_protocols": ["ssh"],
"ssh_port": 22,
"rdp_port": 3389
}
```
- `os_type` set at registration from the agent.
- `console_protocols` lists enabled protocols per server (`ssh`, `rdp`, `vnc`).
- Port fields default to standard ports, overridable in the UI.
### `console_sessions` — new collection (audit)
```json
{
"session_id": "uuid",
"server_id": "uuid",
"protocol": "ssh|rdp|vnc",
"key_id": "uuid | null",
"user": "who opened it",
"started_at": "ISODate",
"ended_at": "ISODate | null",
"client_ip": "string"
}
```
---
## Session Broker + Connection Flow
New service: `server/internal/services/console.go`.
1. Browser `POST /api/console/connect`
`{ server_id, protocol, key_id?, rdp_username?, rdp_password? }`.
2. Broker validates request, loads the server (host IP, port for protocol),
loads the key and **decrypts `private_key_enc` in memory only**.
3. Builds the guacd connection parameter map:
- **SSH:** `hostname`, `port`, `username`, `private-key` (decrypted),
`passphrase` (if any).
- **RDP:** `hostname`, `port`, `username`, `password`, `security=any`,
`ignore-cert=true`.
- **VNC:** `hostname`, `port`, `password`.
4. Creates a `console_sessions` document, returns a short-lived signed session
token.
5. Browser opens WebSocket `/api/console/tunnel?token=…`. The `wwt/guac` handler
validates the token, dials guacd `:4822`, and pipes bytes in both directions.
6. On socket close, the broker sets `ended_at` on the session doc.
### Security
- Decrypted private keys and RDP passwords are **never persisted, never logged,
never sent to the browser** — passed only to guacd.
- Session token: short TTL (~60s to open the WebSocket), single-use,
HMAC-signed, bound to the authenticated user.
- guacd is bound to the internal docker network only; not exposed publicly.
- At-rest encryption (`private_key_enc`, `passphrase_enc`) reuses the existing
AES-256 key already used for agent-generated private keys.
---
## Windows Agent
Same Go codebase as the Linux agent, with a reduced role: **register +
heartbeat + status only**. No `authorized_keys` management (meaningless on
Windows).
- Build target: `GOOS=windows GOARCH=amd64``vantage-agent-windows-amd64.exe`.
- Agent detects OS at registration and sends `os_type=windows`.
- The key-sync loop is disabled on Windows via a runtime OS check (or build tag)
— no `authorized_keys` writes are ever attempted.
- Config file: `C:\ProgramData\vantage\config.yaml`, locked down via ACL to the
equivalent of `0600`.
- Runs as a Windows service via **nssm**.
---
## Windows Installer (MSI)
Agent ships as a WiX v4 MSI produced in CI.
- **WiX v4** chosen because it is a dotnet tool that builds MSIs
**cross-platform** — runs on the Linux Gitea act_runner. (Inno Setup is
Windows-only and does not fit the runner.)
- MSI bundles `vantage-agent.exe`, installs it to `C:\Program Files\Vantage\`,
and registers the nssm service (ships nssm or uses a CustomAction).
- Accepts install parameters as MSI properties for silent/headless install:
```
msiexec /i vantage-agent.msi /qn SERVERID=<id> TOKEN=<token> SERVERURL=vantage..:9090
```
- GUI install (double-click) prompts for server-id / token / server-url via a
dialog.
### Two install paths
1. **Installer direct** — user downloads `vantage-agent.msi`, double-clicks,
fills the dialog. No script required.
2. **PowerShell one-liner** — served dynamically (like the existing bash
`/install`). Script downloads the `.msi`, verifies SHA-256, then runs
`msiexec /qn` with injected `SERVERID` / `TOKEN` / `SERVERURL`. Used by the
copy-paste "Add Server" flow.
The PowerShell script (`/install.ps1`) steps:
1. Detect arch.
2. Download `vantage-agent.msi` from the latest Gitea `agent/v*` release.
3. Verify SHA-256 against `checksums.txt`.
4. Run `msiexec /i vantage-agent.msi /qn SERVERID=.. TOKEN=.. SERVERURL=..`.
---
## Frontend Routes
| Route | Change |
| ------------------------- | ------------------------------------------------------------- |
| `/servers` | Show `os_type` badge, enabled console protocols |
| `/servers/[id]` | Add **Connect** button(s) per enabled protocol |
| `/servers/[id]/console` | New — full-screen console (guacamole-common-js), key picker |
| `/servers/new` | Offer Windows (MSI) vs Linux (bash) install instructions |
Console page: select protocol + SSH key (SSH) or enter RDP credentials, call
`/api/console/connect`, open the tunnel WebSocket, mount the Guacamole client.
---
## CI/CD Changes
### `agent-release.yml`
- Add `windows/amd64` build: `vantage-agent-windows-amd64.exe`.
- Add WiX v4 MSI build job → `vantage-agent.msi`.
- Add both to `checksums.txt` and release assets.
Release assets become:
- `vantage-agent-linux-amd64`
- `vantage-agent-linux-arm64`
- `vantage-agent-windows-amd64.exe`
- `vantage-agent.msi`
- `checksums.txt`
### `server-deploy.yml`
- Add guacd service to `deploy/docker-compose.yml` (deployed alongside server).
---
## New Dependencies
- **Go:** `github.com/wwt/guac` (Guacamole tunnel/WebSocket in Go).
- **Container:** `guacamole/guacd` official image.
- **Frontend:** vendored `guacamole-common-js` (no CDN).
- **CI:** WiX v4 dotnet tool; nssm binary bundled for the MSI.
---
## Open Implementation Notes
- Confirm `wwt/guac` API surface for connection-parameter passing and token auth
binding during implementation.
- nssm packaging inside MSI: bundle the nssm binary as a payload + CustomAction,
or run `sc.exe`-based service install if nssm proves awkward in WiX.
- ACL hardening of `C:\ProgramData\vantage\config.yaml` in the MSI CustomAction.
@@ -1,238 +0,0 @@
# Server Workflows — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Server Workflows only. Fleet Inventory and SaaS/local-auth are separate sub-projects with their own specs.
Approved UI mockup: three-pane builder (Step Library · Canvas · Inspector), env vars shown riding the wire between nodes.
---
## 1. Summary
Let operators compose **reusable shell steps** (Bash or PowerShell) into **workflows** and run them across many managed servers in parallel. Steps pass data to later steps through a `$WORKFLOW_ENV` file (GitHub-Actions style). Every run is recorded with full per-step logs. Steps can reference org secrets, injected as environment variables at runtime.
Builds directly on the existing `CommandStream` gRPC infrastructure (`dispatch.go`, `ServerCommand` oneof, agent command loop).
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Data passing | Implicit. Every step's `$WORKFLOW_ENV` outputs merge into the run's env and are exposed to **all** later steps as `$KEY`. No explicit port wiring. |
| Failure model | Per-step policy: `stop` (default), `continue`, `retry` (with max attempt count). |
| Targets | Fan-out. Same step sequence runs on N target servers **in parallel**. Steps within one server run **sequentially**. |
| History/logs | Every run persisted: status, timing, per-server per-step stdout/stderr/exit code, captured output env. |
| Secrets | Steps declare needed secret keys; resolved from existing `secrets` store and injected as env vars at exec time. Never persisted into run logs. |
| Testing | **Skipped** for this iteration per request. No test files written. |
---
## 3. Data model (MongoDB)
### `workflow_steps` — reusable step library
```json
{
"_id": "ObjectId",
"step_id": "uuid",
"name": "Restart service",
"description": "Restart-Service by name, wait ready",
"interpreter": "bash | powershell",
"script": "Restart-Service vantage-api\n...",
"declared_outputs": ["STARTED_AT"], // documentation/UI hints; not enforced
"secret_refs": ["DEPLOY_TOKEN"], // secret keys this step needs injected
"org_id": "uuid", // for future multi-tenant; single-org for now
"created_at": "ISODate",
"updated_at": "ISODate"
}
```
### `workflows` — ordered composition
```json
{
"_id": "ObjectId",
"workflow_id": "uuid",
"name": "Deploy & Restart API",
"target_server_ids": ["uuid", "uuid"],
"steps": [
{
"step_id": "uuid", // reference to library step
"order": 0,
"on_failure": "stop | continue | retry",
"max_retries": 0, // used when on_failure = retry
"overrides": { // optional local fork of the library step
"script": null,
"secret_refs": null
}
}
],
"created_at": "ISODate",
"updated_at": "ISODate"
}
```
Editing a library step from the Inspector writes an `overrides` block on that workflow step (a local fork) rather than mutating the shared step.
### `workflow_runs` — execution records
```json
{
"_id": "ObjectId",
"run_id": "uuid",
"workflow_id": "uuid",
"workflow_snapshot": { }, // frozen copy of workflow + resolved steps at trigger time
"status": "running | success | failed | cancelled",
"triggered_by": "user-id",
"started_at": "ISODate",
"finished_at": "ISODate | null",
"server_runs": [
{
"server_id": "uuid",
"status": "queued | running | success | failed | skipped",
"started_at": "ISODate | null",
"finished_at": "ISODate | null",
"run_env": { "VERSION": "a1b9f0" }, // accumulated non-secret output env
"steps": [
{
"order": 0,
"name": "Git pull & build",
"status": "success | failed | running | queued | skipped",
"attempts": 1,
"exit_code": 0,
"stdout": "…",
"stderr": "…",
"output_env": { "VERSION": "a1b9f0" },
"started_at": "ISODate",
"finished_at": "ISODate"
}
]
}
]
}
```
Secret values are never written to `stdout`/`stderr`/`run_env` by us; masking of known secret values in captured output is applied before persistence.
---
## 4. gRPC protocol changes (`proto/vantage/v1/vantage.proto`)
### New command in the `ServerCommand` oneof
```protobuf
message RunStepCmd {
string interpreter = 1; // "bash" | "powershell"
string script = 2;
map<string, string> env = 3; // inputs = accumulated run env + injected secrets
int32 timeout_seconds = 4;
}
```
Add `RunStepCmd run_step = 6;` to the `ServerCommand` oneof.
### Richer result — new `AgentMessage` payload
Current `CommandResult{command_id, success, message}` is too thin. Add a dedicated step result:
```protobuf
message StepResult {
string command_id = 1;
int32 exit_code = 2;
string stdout = 3;
string stderr = 4;
map<string, string> output_env = 5; // parsed $WORKFLOW_ENV KEY=value lines
}
```
Add `StepResult step_result = 5;` to the `AgentMessage` oneof (alongside existing `ready` / `result`).
---
## 5. Agent execution (`agent/internal/...`)
New handler for `RunStepCmd` in the agent command loop:
1. Create a temp dir; create empty `WORKFLOW_ENV` file inside it.
2. Write `script` to a temp script file.
3. Build the process environment: inherited env + `cmd.env` (run env + secrets) + `WORKFLOW_ENV=<path to env file>`.
4. Execute:
- `bash``bash <script>`
- `powershell``pwsh -NoProfile -File <script>` (fallback `powershell.exe` on Windows if `pwsh` absent).
5. Capture stdout, stderr, exit code. Enforce `timeout_seconds` (kill on exceed → non-zero exit, stderr note).
6. Parse the `WORKFLOW_ENV` file: each `KEY=value` line becomes an `output_env` entry (last write wins; supports multi-line via simple `KEY<<EOF` heredoc form, optional for v1 — start with single-line `KEY=value`).
7. Reply with `StepResult`. Delete temp dir.
Agent runs as root (existing), so no privilege change. Script content is trusted operator input.
---
## 6. Server orchestration (`server/internal/services/workflows.go`)
Runner responsibilities:
1. On trigger: snapshot the workflow (resolve each library step + overrides), create a `workflow_runs` doc with one `server_run` per target, all `queued`.
2. Spawn one goroutine **per target server** (parallel fan-out). Each goroutine:
- Verifies the agent is connected (`Dispatcher.IsConnected`); if not → `server_run.status = skipped`, reason recorded.
- Maintains a `run_env map[string]string`, seeded empty.
- For each step in order:
- Resolve `secret_refs` from the secrets service → merge into the command env (kept separate from persisted `run_env`).
- Dispatch `RunStepCmd{env: run_env + secrets}` via a **correlated** send — needs a way to await the matching `StepResult` by `command_id` (see §7).
- On result: persist step record (stdout/stderr/exit, masked); merge `output_env` into `run_env`.
- Apply `on_failure` on non-zero exit: `stop` (fail server_run, break), `continue` (mark failed, proceed), `retry` (re-dispatch up to `max_retries`).
3. Aggregate: run `status = success` if all server_runs succeeded, else `failed`. Set `finished_at`.
### Concurrency / queue
- One workflow run per workflow at a time (reject or queue concurrent triggers — v1: reject with clear error).
- Per-server step dispatch is serial; servers are parallel.
---
## 7. Correlated command results
The existing dispatcher is fire-and-forget; workflows need request/response by `command_id`. Add a small **pending-result registry** alongside `Dispatcher`:
- `AwaitResult(commandID) <-chan *pb.StepResult` — registers a channel before dispatch.
- The `CommandStream` receive loop, on a `StepResult`, looks up the pending channel by `command_id` and delivers it (falls back to existing `CommandResult` handling for other command types).
- Timeout guard on the server side (step `timeout_seconds` + grace) so a dead agent can't hang a run.
This is additive; existing `CommandResult` flow for key/update commands is unchanged.
---
## 8. REST API (`server/internal/api/workflows.go`)
| Method + path | Purpose |
|---------------|---------|
| `GET /api/steps` / `POST` / `PUT /:id` / `DELETE /:id` | Reusable step library CRUD |
| `GET /api/workflows` / `POST` / `PUT /:id` / `DELETE /:id` | Workflow CRUD (name, targets, ordered steps) |
| `POST /api/workflows/:id/run` | Trigger a run; returns `run_id` |
| `GET /api/workflows/:id/runs` | Run history (summary list) |
| `GET /api/runs/:run_id` | Full run detail incl. per-server per-step logs |
| `POST /api/runs/:run_id/cancel` | Best-effort cancel |
Secrets are referenced by key only through these APIs; values never returned.
---
## 9. Frontend (`web/app/workflows/`)
- `/workflows` — list workflows, last run status/time, Run button.
- `/workflows/[id]` — the three-pane builder from the approved mockup:
- **Library** (left): reusable steps, `bash`/`pwsh` badges, search, add.
- **Canvas** (center): ordered nodes, env chips on wires, live status pills.
- **Inspector** (right): name, command editor, declared inputs/outputs, `secret_refs` picker, `on_failure` + retry count.
- `/workflows/[id]/runs/[runId]` — run detail: per-server columns, expandable per-step stdout/stderr, exit codes, timing. Live-updating while `running` (poll, consistent with existing 30s-poll ethos — or reuse whatever the console screen uses).
Reuse existing web components/styling patterns (there is already `servers`, `secrets`, `audit`, console UI to match).
---
## 10. Security notes
- Scripts are trusted operator input executed as root — same trust level as the existing console feature. No new sandbox in v1.
- Secret values injected as env only; masked from all persisted logs (`stdout`/`stderr`/`run_env`) by literal replacement before write.
- Run triggering and step/workflow CRUD gated behind existing auth (`server/internal/auth`).
- Audit: emit audit-log entries (existing `audit` service) on workflow create/edit/delete and run trigger.
---
## 11. Out of scope (this iteration)
- Tests (explicitly skipped).
- Branching/conditional steps, matrix per-server conditionals (fan-out only).
- Scheduled/cron triggers (manual run only for v1).
- Multi-org isolation enforcement (schema carries `org_id` for later; single-org behavior now).
- Artifact upload/collection beyond env vars.
@@ -1,150 +0,0 @@
# Workflow Builder v2 — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Overhaul the workflow builder UI to match the approved mockup, add drag-and-drop (library→canvas + reorder), base-step editing/deletion with cascade, step input parameters, an Edit-Workflow modal, runs navigation, and fix the save crash. Enhancement to the merged Server Workflows feature. Independent of the in-flight log-streaming work.
---
## 1. Summary
The shipped builder diverges from the approved mockup and is missing interactions. This iteration:
1. **Restyle** the builder (`/workflows/[id]`) to the approved mockup: dotted-grid canvas, 340px node cards with index badge + shell badge + status, wire connectors with dashed-amber "passes" env chips, a kicker/field inspector, and a library of grabbable step cards with descriptions.
2. **Drag-and-drop**: drag a library step onto the canvas to add it; drag nodes to reorder. Remove the up/down/remove buttons.
3. **Base-step editing**: an "Edit base step" modal edits the shared library step (name/interpreter/script/outputs/inputs/secret refs) and **saves**; deleting a shared step **cascades** — it is pulled from every workflow that references it.
4. **Input parameters**: a base step can declare inputs (`name` + `default` + `description`); when placed, each placement sets values; the runner injects them into the step's environment.
5. **Env visibility**: show the output variables passed between steps as chips on the wires (already partially present — align to the mockup).
6. **Edit-Workflow modal**: edit name, target servers, delete the workflow, and other workflow settings.
7. **Runs navigation**: a runs list page per workflow, linked from the builder and the workflows list.
8. **Bug fix**: `updateWorkflow` returns `{updated:true}`, which the builder stores as the workflow and then crashes on `[...wf.steps]` ("d.steps is not iterable"). Fix the endpoint to return the updated workflow and harden the client.
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Visual target | The approved mockup (artifact `61ab5256`). Adopt its layout + **amber (`#f5a524`) as the builder signal/focus color**, plus bash-green (`#3fb950`) / pwsh-blue (`#5b9bff`) badge colors. Keep the app's existing `surface`/`border`/`text-*` tokens for panels so it integrates with the dark theme. |
| Drag-and-drop | Native HTML5 DnD. Library cards are `draggable`; the canvas has drop targets (between nodes + end zone) to insert; nodes are `draggable` to reorder. Clicking a library card still appends (keyboard/fallback). |
| Step removal | No per-node buttons. Remove a placed step from the **inspector** ("Remove from workflow"). |
| Input params | `WorkflowStep.declared_inputs: [{name, default, description}]`. `WorkflowStepRef.inputs: map[name]value`. Runner resolves `value = ref.inputs[name] ?? default` and injects as env vars. |
| Edit scope | Inspector script edit = **per-placement override** (existing `overrides`, "forks a local copy"). A separate **Edit base step** modal updates the shared library step for all workflows. |
| Cascade delete | Deleting a library step pulls its `step_id` from every `workflow.steps` and re-sequences remaining `order`s. |
| Env chips | Names passed between steps = union of prior steps' `declared_outputs`. Shown on the wire between nodes. |
| Edit workflow | Modal launched from the topbar: name, target-servers multiselect, delete workflow. |
| Runs nav | New page `/workflows/[id]/runs` (list); links from the builder topbar and the workflows list page. |
| Save fix | `PUT /api/workflows/:id` returns the full updated `Workflow`. Client also guards against non-workflow responses. |
| Proto | **No proto change** — input params travel through the existing `RunStepCmd.Env`. |
---
## 3. Data model (`server/internal/models/workflow.go`)
Add an input-parameter type and fields:
```go
type InputParam struct {
Name string `bson:"name" json:"name"`
Default string `bson:"default" json:"default"`
Description string `bson:"description" json:"description"`
}
```
- `WorkflowStep` gains: `DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"``.
- `WorkflowStepRef` gains: `Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`` (per-placement values).
- `ResolvedStep` gains: `Inputs map[string]string `bson:"inputs" json:"inputs"`` (frozen resolved input env for the run).
`declared_inputs` defaults to `[]` on create (like `declared_outputs`).
---
## 4. Services
### 4.1 Step CRUD (`server/internal/services/workflows.go`)
- `CreateStep`: default `DeclaredInputs` to `[]InputParam{}` when nil; persist it.
- `UpdateStep`: add `declared_inputs` to the `$set`.
- `DeleteStep`**cascade**. New behavior: within the delete, also update every workflow that references the step:
1. `DeleteOne` on `workflow_steps` by `step_id` (as today).
2. Load all workflows containing the step (`workflows` where `steps.step_id == stepID`); for each, remove the matching `WorkflowStepRef`(s), re-sequence remaining `order` values to `0..n-1`, and `UpdateWorkflow`.
Keep it a single service call `DeleteStep(stepID)` so the handler is unchanged. Audit both the step deletion and each affected workflow via `LogEvent`.
### 4.2 Workflow update returns the workflow (`server/internal/services/workflows.go` + handler)
- `UpdateWorkflow(id, w)` stays, but the **handler** `updateWorkflow` re-fetches and returns the full workflow: after `services.UpdateWorkflow`, call `services.GetWorkflow(id)` and return it (200 with the `Workflow` JSON) instead of `{"updated": true}`. This is the crash fix's server half.
### 4.3 Runner input injection (`server/internal/services/workflow_runner.go`)
- `resolveSteps`: when freezing each `ResolvedStep`, compute `Inputs`: for each `InputParam` on the library step, `value = ref.Inputs[name]` if present else `param.Default`; store the resulting `map[string]string` on `ResolvedStep.Inputs`.
- `runServer`: when building `cmdEnv`, merge `step.Inputs` **first** (base layer), then `runEnv` (upstream outputs), then `secretVals` (highest precedence). Input values are not secrets and are not masked (they are user-provided config, not secret material) — unless a value coincidentally equals a secret literal, existing masking still catches it in logs.
---
## 5. REST API
No new endpoints. Changes:
- `updateWorkflow` handler returns the `Workflow` (see §4.2).
- `deleteStep` handler unchanged (cascade lives in the service).
- Existing `GET /api/workflows/:id/runs`, `GET /api/runs/:runId` power the runs pages.
- `createStep`/`updateStep` accept `declared_inputs` via the existing `ShouldBindJSON(&models.WorkflowStep)` (no handler change once the model has the field).
---
## 6. Frontend
### 6.1 Tokens + primitives
- `tailwind.config.ts`: add `bash: "#3fb950"`, `pwsh: "#5b9bff"`. Amber signal reuses a new `signal: "#f5a524"` token (add it) for the builder's focus ring / env chips / run button; `signal-ink: "#241800"` for text on amber chips.
- Add a `Modal` primitive to `components/ui` (overlay + centered panel, `onClose`, ESC + backdrop click, `title`, children, exported from `index.ts`). Used by the Edit-base-step and Edit-workflow modals.
### 6.2 API client (`web/lib/api.ts`)
- `WorkflowStep`: add `declared_inputs: InputParam[]`.
- New `InputParam { name: string; default: string; description: string }`.
- `WorkflowStepRef`: add `inputs?: Record<string, string>`.
- `updateWorkflow` return type stays `Workflow` (now actually returns one). Add a client guard: if a mutation is expected to return a workflow but the body lacks `steps`, treat it as an error / refetch.
- No other method changes; `deleteStep`, `updateStep`, `createStep`, `listRuns`, `getRun` already exist.
### 6.3 Builder restyle + DnD (`web/app/workflows/[id]/page.tsx`)
Rebuild the three-pane builder to the mockup:
- **Topbar:** brand dot + `Workflows /` crumb + workflow name + `· draft · edited …`; right side a **Targets** chip (read-only summary, e.g. "3 servers"), a **Runs** link (→ `/workflows/[id]/runs`), an **Edit** button (opens Edit-workflow modal), **Save**, **Run workflow** (amber). Remove the inline name input and the in-canvas Target-servers card (both move into the Edit-workflow modal).
- **Library (left):** section header with `+` (opens Edit-base-step modal in "new" mode), a search box that filters by name, grouped `Shared · Bash` / `Shared · PowerShell`, each a `.step-card` with shell badge, name, description, and a grip glyph; `draggable`. Clicking still appends. Each card has an edit affordance (pencil / context) that opens the Edit-base-step modal for that step.
- **Canvas (center):** dotted-grid background. Render placed steps as 340px node cards: index badge, title, shell badge, (build-time) no status pill — status is a run concern; in the builder show the interpreter badge only. Node body shows a syntax-lite script preview. Between nodes render a wire + a dashed-amber `passes` chip row listing the union of prior `declared_outputs` (names only in the builder). Nodes are `draggable` to reorder; drop targets sit on the wires and at the end ("+ Drop a step here"). Selecting a node opens it in the inspector.
- **Inspector (right):** kicker "Step N · Inspector", title with shell badge + name. Fields: **Step name** (edits the placement label? — placements don't have a name; show the library name read-only, edit happens in the base-step modal), **Command** (script `<textarea>` — this is the per-placement override; empty = inherit base), a hint "Write `KEY=value` to `$WORKFLOW_ENV`…", **Inputs** (one row per `declared_input` with an input to set the placement value, showing the default as placeholder), **Inputs · from upstream** (read-only list of upstream `declared_outputs` available to this step), **Outputs · to $WORKFLOW_ENV** (read-only list of this step's `declared_outputs`), **Secret refs** (existing group/KEY checklist), **On failure** (stop/continue/retry + max retries), and **Remove from workflow**.
DnD detail: use `dataTransfer` with a payload discriminating "library step" (carries `step_id`) vs "reorder" (carries the placement index). On drop at position `k`, insert/move and re-sequence `order`. Keep everything in React state; **Save** persists via `updateWorkflow` (which now returns the workflow → `setWf(returned)` no longer crashes).
### 6.4 Edit base step modal
A `Modal` with fields: name, interpreter (bash/powershell), script (`<textarea>` mono), declared **outputs** (chip/list editor — add/remove names), declared **inputs** (rows of name/default/description, add/remove), secret refs (optional). Actions: **Save** (`api.createStep` in new mode / `api.updateStep` in edit mode) then invalidate `["steps"]`; **Delete** (edit mode only) → confirm, `api.deleteStep` (cascades server-side), invalidate `["steps"]` and `["workflow", id]` (a deleted step vanishes from the canvas after refetch). Editing here changes the shared step for all workflows (surface the "shared across all workflows" hint).
### 6.5 Edit workflow modal
A `Modal` launched from the topbar **Edit** button: workflow **name** input, **target servers** multiselect (the chips currently in the canvas), and a **Delete workflow** action (confirm → `api.deleteWorkflow` → route to `/workflows`). Save applies name/targets to local `wf` state (persisted on the builder's Save) or immediately via `updateWorkflow` — immediate is simpler and avoids losing the change; use immediate save for the modal, then `setWf(returned)`.
### 6.6 Runs list page (`web/app/workflows/[id]/runs/page.tsx`)
New page: header "Runs · <workflow name>", a table of `api.listRuns(id)` rows — run id (short), status badge, started_at, triggered_by, server count — each linking to `/workflows/[id]/runs/[runId]`. A "Back to builder" link. Also add a **Runs** action/link on the workflows list page (`web/app/workflows/page.tsx`) per row and the **Runs** link in the builder topbar.
---
## 7. Security
- Input parameter values are user config, injected as env; not masked (not secret). Secret masking (existing) still applies to logs and to any value equal to a secret literal.
- Cascade delete is an authenticated mutation; audited via `LogEvent` for the step and each affected workflow.
- Modals perform the same session-authed API calls; no new trust boundary.
---
## 8. Out of scope
- Live run status pills inside the builder canvas (status belongs to the run detail page).
- Typed inputs (all inputs are strings), required/validation rules, secret-typed inputs.
- Multi-select drag of several steps, copy/paste of steps, undo/redo.
- Reworking the run-detail page (covered by the separate log-streaming iteration).
- Reordering via keyboard.
- Tests (skipped, consistent with prior iterations).
```
@@ -1,193 +0,0 @@
# Workflow Log Streaming — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Stream step stdout/stderr live from agent to server-side log files, tail them live in the UI, and auto-expire them on a retention period. Enhancement to the already-merged Server Workflows feature. No auth/orgs, no inventory.
---
## 1. Summary
Today a workflow step buffers all stdout/stderr in agent RAM, ships it in one terminal `StepResult`, and the server persists the whole body into the `workflow_runs` Mongo document. Long/chatty steps risk: agent memory blow-up, the gRPC 4MB message ceiling, and the Mongo 16MB document cap.
Change to **live streaming**:
1. Agent streams output chunks over the existing `CommandStream` as the process runs.
2. Server appends chunks (secret-masked) to a **per-server-run log file** on disk — not Mongo.
3. UI tails the file live via **SSE** while a server-run is running; slices per-step by byte offset after completion.
4. A **retention sweeper** deletes old run-log directories on a configurable period (default 30 days, set in Settings).
`workflow_runs` documents shrink: they no longer carry `stdout`/`stderr` bodies, only status/exit/attempts/output_env/timestamps plus a per-step `log_offset`.
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Transport | Reuse bidirectional `CommandStream`. New `AgentMessage.StepOutput` chunk message. |
| Chunk shape | `{command_id, seq, data, eof}`. Interleaved stdout+stderr in execution order. |
| Terminal result | `StepResult` still sent at step end, now carries only `exit_code` + `output_env` (no stdout/stderr). |
| Log granularity | **One file per server-run**: `<logdir>/<run_id>/<server_id>.log`, with a marker line before each step. |
| Streams | **Interleaved** — single synchronized writer on the agent, terminal-order output. |
| Masking | **Server-side** (agent can't tell secret env from normal env). Per-stream carry buffer of `maxSecretLen-1` bytes so a secret split across a chunk boundary still masks; flushed on EOF. |
| Live tail | **SSE** at per-server-run granularity: `GET /api/runs/:runId/servers/:serverId/logs/stream`. Post-run whole-file fetch + per-step offset slice. |
| Retention | `settings.workflow_log_retention_days`, default **30**, editable in `/settings`. Hourly sweeper deletes `<logdir>/<run_id>/` dirs older than retention by run `finished_at`. |
| Log dir | Env `VANTAGE_WORKFLOW_LOG_DIR`, default `<data>/workflow-logs`. Created `0700`. |
| Mongo | No log bodies in `workflow_runs`. Disk is the source of truth for output. |
---
## 3. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
Add to the `AgentMessage` oneof: `StepOutputChunk step_output = 6;`
```protobuf
message StepOutputChunk {
string command_id = 1;
uint64 seq = 2; // monotonic per command_id, 0-based
bytes data = 3; // raw interleaved stdout+stderr bytes
bool eof = 4; // true on the final (empty) chunk
}
```
`StepResult` is unchanged in shape but `stdout`/`stderr` are now left empty by the agent (kept in the message for backward-compat / error notes only — server ignores them for log content). The server still reads `exit_code` and `output_env` from `StepResult`.
Hand-written JSON-codec struct added to **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, identical. `AgentMessage` gains `StepOutput *StepOutputChunk` in both.
`data` is `[]byte` in the Go structs (JSON-codec base64-encodes it, which is fine).
---
## 4. Agent (`agent/internal/exec/exec.go`)
`RunStep` signature gains a chunk sink:
```go
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult
```
- Replace the two `bytes.Buffer`s with a single `streamWriter` set as **both** `c.Stdout` and `c.Stderr`. Its `Write` takes a mutex (so stdout+stderr interleave without interleaving *within* a write), assigns the next `seq`, and calls `emit(seq, copyOfBytes)`. Chunks are whatever the OS pipe delivers (typically ≤64KB); no extra buffering/line-assembly.
- `StepResult` returns with `Stdout`/`Stderr` empty; `ExitCode` and `OutputEnv` populated as today (env parsing unchanged).
- On timeout/exec error, put the short note in `StepResult.Stderr` (terminal, not streamed) so the runner can still surface a failure reason even if nothing streamed.
Agent loop (`agent/internal/sync/sync.go`, the `cmd.RunStep != nil` goroutine): pass an `emit` closure that sends `AgentMessage{ServerId, AgentToken, StepOutput: &pb.StepOutputChunk{CommandId, Seq, Data}}` through the existing mutex-guarded `send()`. After `RunStep` returns, send a final `StepOutput{eof:true, seq:last+1}` then the terminal `StepResult` (both via `send()`). Ordering: all chunks, then eof, then StepResult.
---
## 5. Server write path
### 5.1 Log writer registry (`server/internal/services/steplogs.go`)
Parallel to `StepResults`. Keyed by `command_id`:
```go
type stepLogWriter struct {
f *os.File
mu sync.Mutex
carry []byte // held-back tail for boundary-safe masking
secrets []string // secret literals to mask
maxSecret int
}
var StepLogs = &stepLogRegistry{ ... }
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) (*stepLogWriter, error)
func (r *stepLogRegistry) Append(commandID string, data []byte) // masked write
func (r *stepLogRegistry) Close(commandID string) // flush carry, close file
```
- `Append` masking: concatenate `carry+data`, mask all secret literals (`ReplaceAll(v,"***")`), then write everything except the last `maxSecret-1` bytes; keep those as the new `carry`. `Close` masks+writes the remaining carry. If `secrets` empty, write straight through (no carry).
- The file handle is opened append-only (`O_APPEND|O_CREATE|O_WRONLY`, `0600`); dir `0700`.
### 5.2 Stream delivery (`server/internal/grpc/server.go`)
In the receive loop, after the `m.StepResult` block, add:
```go
if m.StepOutput != nil {
if m.StepOutput.Eof {
services.StepLogs.Close(m.StepOutput.CommandId)
} else {
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
}
}
```
### 5.3 Runner changes (`server/internal/services/workflow_runner.go`)
- Resolve the log dir + run/server file path once per server-run; ensure `<logdir>/<run_id>/` exists.
- Before dispatching each step: write the step marker line to the file (`\n===== step <order>: <name> =====\n`), record the current file byte offset as the step's `log_offset` (persisted on the `StepRun`), and `StepLogs.Open(commandID, path, secretVals)` **before** `DispatchRunStep` (same ordering rule as `StepResults.Await`).
- `dispatchAndWait` no longer expects stdout/stderr in the result. On terminal `StepResult`, `StepLogs.Close(commandID)` is driven by the agent's eof; the runner also calls `Close` defensively on timeout/dispatch-failure (idempotent).
- **Drop** `stdout`/`stderr` from `finishStep` persistence. Masking of the streamed body is done in `Append`; `run_env`/`output_env` masking (existing, from the merged fix) stays.
- The shared server-run file is written by two writers that never overlap in time (steps are serial, and the runner writes each step marker *before* `StepLogs.Open`): (a) the runner writes markers directly to the path, serially between steps; (b) `StepLogs` writes chunks during a step. **Resolved approach:** `Open(commandID, path, secrets)` opens the path fresh with `O_APPEND|O_CREATE|O_WRONLY` for that step and `Close(commandID)` closes it on eof. One handle live at a time per server-run (serial steps guarantee this), so there is no shared-handle race and no ref-counting. The runner's marker write is a separate short `O_APPEND` open/write/close on the same path.
### 5.4 Data model (`server/internal/models/workflow.go`)
`StepRun`:
- **Remove** `Stdout`, `Stderr` string fields.
- **Add** `LogOffset int64 `bson:"log_offset" json:"log_offset"`` — byte offset in the server-run file where this step's marker begins.
`ServerRun` gains nothing structural (its file path is derivable: `<logdir>/<run_id>/<server_id>.log`).
---
## 6. REST API (`server/internal/api/workflows.go`)
- `GET /api/runs/:runId/servers/:serverId/logs` — returns the whole server-run log file (`text/plain`). 404 if absent. Used post-run and as SSE fallback.
- `GET /api/runs/:runId/servers/:serverId/logs/stream`**SSE**. Opens the file, streams existing content as `data:` events, then polls for appends (~500ms) emitting new bytes, until the server-run status is terminal (success/failed/skipped/cancelled) AND no more bytes, then sends a final `event: done` and closes. Sets `Content-Type: text/event-stream`, disables gin's buffering. Guards against path traversal (runId/serverId are used as literal path segments — validate they are UUIDs / contain no separators).
Log content served by these endpoints is already masked (masking happens at write time), so no masking needed on read.
---
## 7. Retention
### 7.1 Setting
`settings` collection gains `workflow_log_retention_days int` (default 30 when unset). Read/write via the existing settings service + surfaced in `/settings` UI as a number input. `0` or negative disables sweeping (keep forever) — document this.
### 7.2 Sweeper (`server/internal/services/steplogs.go` or `logsweeper.go`)
- `StartLogSweeper()` launched at server startup (next to index setup): hourly `time.Ticker`.
- Each tick: read retention setting; if ≤0 skip. Compute cutoff = `now - retentionDays`. For each `<logdir>/<run_id>/` dir, look up the run's `finished_at` (query `workflow_runs` by run_id); if finished and older than cutoff, `os.RemoveAll` the dir. Fallback to dir mtime if the run doc is gone.
- Also run once at startup.
---
## 8. Frontend
### 8.1 API client (`web/lib/api.ts`)
- `StepRun`: remove `stdout`/`stderr`; add `log_offset: number`.
- Add `getServerRunLog(runId, serverId): Promise<string>` (GET .../logs).
- SSE consumed directly via `EventSource` in the component (not through the `request` helper), URL built from the same base.
- Settings type gains `workflow_log_retention_days`.
### 8.2 Run detail (`web/app/workflows/[id]/runs/[runId]/page.tsx`)
- Per-server card: while the server-run is `running`, open an `EventSource` to the stream endpoint and render a live `<pre>` terminal that appends incoming chunks (auto-scroll). Close the source on `event: done`, unmount, or terminal status.
- After completion: fetch the whole file once and render it; step `<details>` still list status/exit/attempts pills. (Per-step slicing by `log_offset` is optional polish — v1 may show the whole server log under the card and keep step pills as the status summary.)
- Remove reliance on `st.stdout`/`st.stderr` (fields gone).
### 8.3 Settings (`web/app/settings/page.tsx`)
- Add a "Workflow log retention (days)" number input bound to `workflow_log_retention_days`, saved via the existing settings mutation. Note that `0` = keep forever.
---
## 9. Security
- Secret masking moves to the streaming write path but remains server-side and boundary-safe (carry buffer). Same `***` replacement.
- Log files `0600`, dirs `0700`, under a dedicated log dir.
- SSE/read endpoints validate `runId`/`serverId` as UUID-shaped path segments to prevent traversal; they are session-authed (same `apiGroup`).
- Terminal `StepResult.Stderr` (error notes only) is still masked before any persistence (it is no longer persisted as log body; if surfaced, mask against secretVals).
---
## 10. Out of scope
- Per-step (rather than per-server) live SSE channels.
- Log compression / rotation within a run, remote log storage (S3), download-as-zip.
- Full-text search over logs.
- Backfilling/migrating already-existing `workflow_runs` stdout/stderr into files (pre-existing runs keep whatever they had; new field just won't be set — acceptable, feature is new).
- Tests (skipped, consistent with the Workflows iteration).
```
+19
View File
@@ -36,3 +36,22 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #3e4160;
}
@keyframes led-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.led-pulse { animation: led-pulse 1.4s ease-in-out infinite; }
@keyframes cell-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
50% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 0); }
}
.cell-ring { animation: cell-ring 1.4s ease-in-out infinite; }
@keyframes caret-blink { 50% { opacity: 0; } }
.caret-blink { animation: caret-blink 1s step-end infinite; }
@media (prefers-reduced-motion: reduce) {
.led-pulse, .cell-ring, .caret-blink { animation: none; }
}
+436 -77
View File
@@ -1,40 +1,125 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ServerRun, StepRun } from "@/lib/api";
import { Button, Badge, Card } from "@/components/ui";
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
import { Button } from "@/components/ui";
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
// ---- status vocabulary ----------------------------------------------------
const statusVariant: Record<string, BadgeVariant> = {
success: "success",
failed: "danger",
running: "accent",
queued: "neutral",
skipped: "neutral",
cancelled: "warning",
};
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
function StatusBadge({ status }: { status: string }) {
return <Badge variant={statusVariant[status] ?? "neutral"}>{status}</Badge>;
function cellKind(status: string): CellKind {
switch (status) {
case "success":
return "done";
case "failed":
return "fail";
case "running":
return "run";
case "skipped":
return "skip";
case "cancelled":
return "warn";
default:
return "wait"; // queued / pending / missing
}
}
function ServerLog({
const cellGlyph: Record<CellKind, string> = {
done: "✓",
fail: "✕",
run: "●",
wait: "○",
skip: "",
warn: "!",
};
const cellClass: Record<CellKind, string> = {
done: "bg-success/15 text-success",
fail: "bg-danger/15 text-danger",
run: "bg-accent/15 text-accent",
wait: "text-border",
skip: "text-text-secondary",
warn: "bg-warning/15 text-warning",
};
// ---- run-level status pill ------------------------------------------------
type PillKind = "running" | "success" | "failed" | "neutral";
function pillKind(status: string): PillKind {
if (status === "running") return "running";
if (status === "success") return "success";
if (status === "failed" || status === "cancelled") return "failed";
return "neutral";
}
const pillClass: Record<PillKind, string> = {
running: "text-accent border-accent/40 bg-accent/10",
success: "text-success border-success/35 bg-success/10",
failed: "text-danger border-danger/35 bg-danger/10",
neutral: "text-text-secondary border-border bg-surface-2",
};
const pillLed: Record<PillKind, string> = {
running: "bg-accent led-pulse",
success: "bg-success",
failed: "bg-danger",
neutral: "bg-text-secondary",
};
function StatusPill({ status, small }: { status: string; small?: boolean }) {
const kind = pillKind(status);
return (
<span
className={`inline-flex items-center gap-2 rounded-full border font-mono font-semibold uppercase tracking-wide ${
small ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"
} ${pillClass[kind]}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${pillLed[kind]}`} />
{status}
</span>
);
}
// ---- time helpers ---------------------------------------------------------
function fmtDuration(ms: number): string {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function stepDuration(st: StepRun, running: boolean, now: number): string {
if (!st.started_at) return st.status === "queued" ? "queued" : "";
const start = new Date(st.started_at).getTime();
const end = st.finished_at ? new Date(st.finished_at).getTime() : running ? now : start;
return fmtDuration(end - start);
}
// ---- live log terminal ----------------------------------------------------
function LogTerminal({
runId,
serverId,
status,
server,
}: {
runId: string;
serverId: string;
status: string;
server: ServerRun;
}) {
const [text, setText] = useState("");
const preRef = useRef<HTMLPreElement>(null);
const running = status === "running";
const preRef = useRef<HTMLDivElement>(null);
const running = server.status === "running";
const serverId = server.server_id;
useEffect(() => {
setText("");
if (running) {
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), {
withCredentials: true,
@@ -44,7 +129,6 @@ function ServerLog({
es.onerror = () => es.close();
return () => es.close();
}
// terminal: fetch the whole file once
api
.getServerRunLog(runId, serverId)
.then(setText)
@@ -55,19 +139,203 @@ function ServerLog({
preRef.current?.scrollTo(0, preRef.current.scrollHeight);
}, [text]);
const activeStep =
server.steps.find((s) => s.status === "running") ??
[...server.steps].reverse().find((s) => s.started_at);
return (
<pre
ref={preRef}
className="mt-3 max-h-80 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 font-mono text-xs text-text-secondary"
>
{text || (running ? "Waiting for output…" : "No output.")}
</pre>
<div className="overflow-hidden rounded-xl border border-border bg-[#0a0b10]">
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface px-4 py-3">
<span className="truncate font-mono text-[13px] font-semibold text-text-primary">
{activeStep ? activeStep.name : "Output"}{" "}
<span className="font-normal text-text-secondary">{server.hostname}</span>
</span>
{running && (
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-wide text-accent">
<span className="h-1.5 w-1.5 rounded-full bg-accent led-pulse" />
Streaming
</span>
)}
</div>
<div
ref={preRef}
className="max-h-[340px] overflow-auto whitespace-pre-wrap px-4 py-3.5 font-mono text-[12.5px] leading-relaxed text-text-secondary"
>
{text || (running ? "Waiting for output…" : "No output.")}
{running && text && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] translate-y-[2px] bg-accent caret-blink align-baseline" />
)}
</div>
</div>
);
}
// ---- step list ------------------------------------------------------------
function StepList({
server,
now,
}: {
server: ServerRun;
now: number;
}) {
const running = server.status === "running";
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<span className="font-mono text-[13px] font-semibold text-text-primary">
Steps <span className="font-normal text-text-secondary">{server.steps.length}</span>
</span>
<StatusPill status={server.status} small />
</div>
<div className="flex flex-col gap-0.5 p-1.5">
{server.steps.map((st) => {
const kind = cellKind(st.status);
return (
<div
key={st.order}
className={`grid grid-cols-[20px_1fr_auto] items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] hover:bg-surface-2 ${
st.status === "running" ? "bg-accent/[0.06]" : ""
}`}
>
<span className="text-right font-mono text-[11px] text-text-secondary">
{String(st.order + 1).padStart(2, "0")}
</span>
<span className="flex items-center gap-2 font-medium text-text-primary">
<span className={`font-mono ${cellClass[kind].replace(/bg-\S+/, "")}`}>
{cellGlyph[kind]}
</span>
{st.name}
</span>
<span className="text-right font-mono text-[10.5px] text-text-secondary">
{st.status === "failed" && (
<span className="text-danger">exit {st.exit_code} · </span>
)}
{st.attempts > 1 ? `${st.attempts} tries` : "1 try"}
{stepDuration(st, running, now) ? ` · ${stepDuration(st, running, now)}` : ""}
</span>
</div>
);
})}
{server.steps.length === 0 && (
<p className="px-3 py-2 text-xs text-text-secondary">No steps yet.</p>
)}
</div>
</div>
);
}
// ---- execution matrix (signature) -----------------------------------------
interface Column {
order: number;
name: string;
}
function buildColumns(run: WorkflowRun): Column[] {
const byOrder = new Map<number, string>();
for (const sr of run.server_runs) {
for (const st of sr.steps) {
if (!byOrder.has(st.order)) byOrder.set(st.order, st.name);
}
}
return [...byOrder.entries()]
.map(([order, name]) => ({ order, name }))
.sort((a, b) => a.order - b.order);
}
function ExecutionMatrix({
run,
columns,
selected,
onSelect,
}: {
run: WorkflowRun;
columns: Column[];
selected: string;
onSelect: (serverId: string) => void;
}) {
return (
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
<table className="w-full border-collapse font-mono text-[12.5px]">
<thead>
<tr>
<th className="border-b border-border px-4 py-3 text-left align-bottom text-xs font-semibold uppercase tracking-wider text-text-primary">
Server
</th>
{columns.map((c) => (
<th
key={c.order}
className="whitespace-nowrap border-b border-border px-3.5 py-3 align-bottom text-[11px] font-medium text-text-secondary"
>
<span className="block text-[10px] text-border">
{String(c.order + 1).padStart(2, "0")}
</span>
{c.name}
</th>
))}
</tr>
</thead>
<tbody>
{run.server_runs.map((sr) => {
const byOrder = new Map(sr.steps.map((s) => [s.order, s]));
const isSel = sr.server_id === selected;
return (
<tr
key={sr.server_id}
onClick={() => onSelect(sr.server_id)}
className={`cursor-pointer ${isSel ? "bg-accent/5" : "hover:bg-white/[0.02]"}`}
>
<th className="whitespace-nowrap border-b border-r border-border px-4 py-3 text-left font-medium text-text-primary">
{sr.hostname}
<StatusPill status={sr.status} small />
</th>
{columns.map((c) => {
const st = byOrder.get(c.order);
const kind = st ? cellKind(st.status) : "wait";
return (
<td
key={c.order}
className="relative border-b border-r border-border last:border-r-0"
>
<span className="flex h-[54px] items-center justify-center">
<span
className={`relative flex h-[26px] w-[26px] items-center justify-center rounded-md ${cellClass[kind]}`}
>
{kind === "run" && (
<span className="absolute inset-0 rounded-md border border-accent/50 cell-ring" />
)}
{cellGlyph[kind]}
</span>
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ---- page -----------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3 mt-8 flex items-center gap-2.5 font-mono text-[11px] uppercase tracking-widest text-text-secondary">
{children}
<span className="h-px flex-1 bg-border" />
</div>
);
}
export default function RunDetail() {
const { runId } = useParams<{ runId: string }>();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const { data: run, isLoading } = useQuery({
queryKey: ["run", runId],
@@ -75,6 +343,27 @@ export default function RunDetail() {
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
});
const running = run?.status === "running";
// tick the elapsed clock while running
useEffect(() => {
if (!running) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [running]);
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
// default selection: first running server, else first server
const selectedServer = useMemo(() => {
if (!run || run.server_runs.length === 0) return null;
if (selected) {
const match = run.server_runs.find((s) => s.server_id === selected);
if (match) return match;
}
return run.server_runs.find((s) => s.status === "running") ?? run.server_runs[0];
}, [run, selected]);
const cancel = async () => {
await api.cancelRun(runId);
queryClient.invalidateQueries({ queryKey: ["run", runId] });
@@ -84,61 +373,131 @@ export default function RunDetail() {
return <div className="p-8 text-text-secondary">Loading</div>;
}
const totalSteps = run.server_runs.reduce((n, s) => n + s.steps.length, 0);
const doneSteps = run.server_runs.reduce(
(n, s) => n + s.steps.filter((st) => st.status === "success").length,
0
);
const succeeded = run.server_runs.filter((s) => s.status === "success").length;
const failed = run.server_runs.filter(
(s) => s.status === "failed" || s.status === "cancelled"
).length;
const startMs = run.started_at ? new Date(run.started_at).getTime() : now;
const endMs = run.finished_at ? new Date(run.finished_at).getTime() : now;
const elapsed = fmtDuration(endMs - startMs);
const ago = fmtDuration(now - startMs);
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="mx-auto max-w-[1180px] p-8 pb-16">
{/* identity bar */}
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
<h1 className="text-2xl font-bold text-text-primary">{run.name}</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-text-secondary">
<span>Run {run.run_id.slice(0, 8)}</span>
<StatusBadge status={run.status} />
</p>
<div className="mb-2 font-mono text-xs uppercase tracking-wide text-text-secondary">
Workflows / {run.name} / Runs
</div>
<h1 className="text-[28px] font-semibold tracking-tight text-text-primary">
{run.name}
</h1>
<div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-[12.5px] text-text-secondary">
<span>
run <b className="font-medium text-text-primary">{run.run_id.slice(0, 8)}</b>
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
triggered by{" "}
<b className="font-medium text-text-primary">{run.triggered_by || "—"}</b>
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
started <b className="font-medium text-text-primary">{ago}</b> ago
</span>
<span className="h-[3px] w-[3px] rounded-full bg-border" />
<span>
elapsed <b className="font-medium text-text-primary">{elapsed}</b>
</span>
</div>
</div>
<div className="flex items-center gap-3.5">
<StatusPill status={run.status} />
{running && (
<Button variant="danger" onClick={cancel}>
Cancel run
</Button>
)}
</div>
{run.status === "running" && (
<Button variant="danger" onClick={cancel}>
Cancel
</Button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{run.server_runs.map((sr: ServerRun) => (
<Card key={sr.server_id}>
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-text-primary">{sr.hostname}</span>
<StatusBadge status={sr.status} />
</div>
<div className="space-y-2">
{sr.steps.map((st: StepRun) => (
<div
key={st.order}
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-surface-2 p-2"
>
<span className="text-sm text-text-primary">{st.name}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-text-secondary">
attempts: {st.attempts}
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
</span>
<StatusBadge status={st.status} />
</span>
</div>
))}
{sr.steps.length === 0 && (
<p className="text-xs text-text-secondary">No steps yet.</p>
)}
</div>
<ServerLog
runId={run.run_id}
serverId={sr.server_id}
status={sr.status}
/>
</Card>
))}
{run.server_runs.length === 0 && (
<p className="text-text-secondary">No servers targeted by this run.</p>
)}
{/* summary strip */}
<div className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-4">
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Servers
</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">
{run.server_runs.length}
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Succeeded
</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-success">
{succeeded}
<small className="text-sm font-medium text-text-secondary">
{" "}
/ {run.server_runs.length}
</small>
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Failed
</div>
<div
className={`mt-1 font-mono text-[22px] font-semibold tabular-nums ${
failed > 0 ? "text-danger" : "text-text-primary"
}`}
>
{failed}
</div>
</div>
<div className="bg-surface px-[18px] py-4">
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">
Steps done
</div>
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">
{doneSteps}
<small className="text-sm font-medium text-text-secondary"> / {totalSteps}</small>
</div>
</div>
</div>
{run.server_runs.length === 0 ? (
<p className="mt-8 text-text-secondary">No servers targeted by this run.</p>
) : (
<>
<SectionLabel>Execution matrix</SectionLabel>
<ExecutionMatrix
run={run}
columns={columns}
selected={selectedServer?.server_id ?? ""}
onSelect={setSelected}
/>
{selectedServer && (
<>
<SectionLabel>
{selectedServer.hostname}&nbsp;·&nbsp;steps &amp; live output
</SectionLabel>
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-[320px_1fr]">
<StepList server={selectedServer} now={now} />
<LogTerminal runId={run.run_id} server={selectedServer} />
</div>
</>
)}
</>
)}
</div>
);
}
File diff suppressed because one or more lines are too long