Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea545e873 | ||
|
|
82d7dde5f8 | ||
|
|
397016ad68 |
@@ -35,10 +35,21 @@ func (w *streamWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// WorkspacePath returns the per-run working directory for a workspace id. The
|
||||
// same id always maps to the same path so RunStep and the cleanup command agree.
|
||||
func WorkspacePath(workspaceID string) string {
|
||||
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
|
||||
}
|
||||
|
||||
// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for
|
||||
// the script to append KEY=value output to, executes it under the requested
|
||||
// interpreter, and streams output via emit, returning the terminal result
|
||||
// with empty stdout/stderr but populated exit_code/output_env.
|
||||
//
|
||||
// When the command carries a WorkspaceId the step runs with that per-run working
|
||||
// directory as its cwd (created here if missing); the server removes it once the
|
||||
// run finishes. The script and env files always live in a private temp dir so
|
||||
// they never leak into the shared workspace.
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
|
||||
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
|
||||
|
||||
@@ -50,6 +61,16 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
workDir := ""
|
||||
if cmd.WorkspaceId != "" {
|
||||
workDir = WorkspacePath(cmd.WorkspaceId)
|
||||
if err := os.MkdirAll(workDir, 0700); err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "create workspace: " + err.Error()
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
envFile := filepath.Join(dir, "workflow_env")
|
||||
if err := os.WriteFile(envFile, nil, 0600); err != nil {
|
||||
res.ExitCode = 1
|
||||
@@ -91,6 +112,10 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
c = exec.CommandContext(ctx, "bash", scriptPath)
|
||||
}
|
||||
|
||||
if workDir != "" {
|
||||
c.Dir = workDir
|
||||
}
|
||||
|
||||
c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile)
|
||||
for k, v := range cmd.Env {
|
||||
c.Env = append(c.Env, k+"="+v)
|
||||
|
||||
@@ -63,12 +63,19 @@ type ReportUpdatesResponse struct{}
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -110,6 +117,9 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
// WorkspaceId names the per-run working directory the agent creates and uses
|
||||
// as the step's cwd. Empty means run in the agent's default directory.
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
|
||||
@@ -198,6 +198,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
}
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -286,6 +289,16 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
id := cmd.CleanupWorkspace.WorkspaceId
|
||||
dir := agentexec.WorkspacePath(id)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
log.Printf("cleanup workspace %s failed (cmd=%s): %v", dir, cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
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).
|
||||
```
|
||||
@@ -92,9 +92,16 @@ message ServerCommand {
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
message CleanupWorkspaceCmd {
|
||||
string workspace_id = 1;
|
||||
}
|
||||
|
||||
message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
@@ -117,6 +124,7 @@ message RunStepCmd {
|
||||
string script = 2;
|
||||
map<string, string> env = 3;
|
||||
int32 timeout_seconds = 4;
|
||||
string workspace_id = 5; // per-run working dir the agent creates & uses as cwd
|
||||
}
|
||||
|
||||
message StepResult {
|
||||
|
||||
@@ -66,12 +66,19 @@ type ReportUpdatesResponse struct{}
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -113,6 +120,9 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
// WorkspaceId names the per-run working directory the agent creates and uses
|
||||
// as the step's cwd. Empty means run in the agent's default directory.
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
|
||||
@@ -68,6 +68,19 @@ func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
|
||||
}
|
||||
|
||||
// DispatchCleanupWorkspace tells a server's agent to remove a run's working
|
||||
// directory. Best-effort and fire-and-forget: if the agent is gone the temp dir
|
||||
// is reclaimed by the OS on reboot anyway.
|
||||
func DispatchCleanupWorkspace(serverID, workspaceID string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
}
|
||||
_ = Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
CleanupWorkspace: &pb.CleanupWorkspaceCmd{WorkspaceId: workspaceID},
|
||||
})
|
||||
}
|
||||
|
||||
// KeyGenParams carries all options for a generate-key command.
|
||||
type KeyGenParams struct {
|
||||
Label string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -26,9 +27,15 @@ 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) {
|
||||
// logTS is the UTC timestamp prefix stamped on every log line. Stored in UTC
|
||||
// (RFC3339, millisecond precision); the UI renders it in the viewer's timezone.
|
||||
func logTS() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
|
||||
}
|
||||
|
||||
// AppendMarker writes a timestamped event 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, text string) (int64, error) {
|
||||
path := ServerRunLogPath(runID, serverID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return 0, err
|
||||
@@ -39,7 +46,7 @@ func AppendMarker(runID, serverID, line string) (int64, error) {
|
||||
}
|
||||
defer f.Close()
|
||||
off, _ := f.Seek(0, 2) // current end = offset before write
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
|
||||
return off, err
|
||||
}
|
||||
return off, nil
|
||||
@@ -48,11 +55,10 @@ func AppendMarker(runID, serverID, line string) (int64, error) {
|
||||
// ---- streamed chunk writer, boundary-safe secret masking ----
|
||||
|
||||
type stepLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
carry []byte
|
||||
secrets []string
|
||||
maxSecret int
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
carry []byte // bytes of an as-yet-unterminated line
|
||||
secrets []string
|
||||
}
|
||||
|
||||
type stepLogRegistry struct {
|
||||
@@ -71,13 +77,7 @@ func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
|
||||
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}
|
||||
w := &stepLogWriter{f: f, secrets: secrets}
|
||||
r.mu.Lock()
|
||||
r.writers[commandID] = w
|
||||
r.mu.Unlock()
|
||||
@@ -90,8 +90,10 @@ func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
|
||||
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.
|
||||
// Append buffers chunks into whole lines, then writes each complete line with a
|
||||
// UTC timestamp prefix and secret masking applied. Buffering by line means a
|
||||
// secret split across a chunk boundary is always masked (the whole line is
|
||||
// assembled first) and every line carries its own timestamp.
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
w := r.get(commandID)
|
||||
if w == nil {
|
||||
@@ -99,22 +101,27 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
}
|
||||
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
|
||||
for {
|
||||
i := bytes.IndexByte(buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
w.writeLine(buf[:i])
|
||||
buf = buf[i+1:]
|
||||
}
|
||||
flush := buf[:len(buf)-hold]
|
||||
w.carry = append([]byte{}, buf[len(buf)-hold:]...)
|
||||
_, _ = w.f.Write(maskBytes(flush, w.secrets))
|
||||
w.carry = append([]byte{}, buf...)
|
||||
}
|
||||
|
||||
// Close flushes the carry (masked) and closes the file.
|
||||
// writeLine emits one masked, timestamped log line. Caller holds w.mu.
|
||||
func (w *stepLogWriter) writeLine(line []byte) {
|
||||
masked := maskBytes(line, w.secrets)
|
||||
_, _ = w.f.WriteString("[" + logTS() + "] ")
|
||||
_, _ = w.f.Write(masked)
|
||||
_, _ = w.f.WriteString("\n")
|
||||
}
|
||||
|
||||
// Close flushes any trailing partial line and closes the file.
|
||||
func (r *stepLogRegistry) Close(commandID string) {
|
||||
r.mu.Lock()
|
||||
w := r.writers[commandID]
|
||||
@@ -126,7 +133,7 @@ func (r *stepLogRegistry) Close(commandID string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.carry) > 0 {
|
||||
_, _ = w.f.Write(maskBytes(w.carry, w.secrets))
|
||||
w.writeLine(w.carry)
|
||||
w.carry = nil
|
||||
}
|
||||
_ = w.f.Close()
|
||||
|
||||
@@ -162,16 +162,20 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
fin := time.Now()
|
||||
_, _ = AppendMarker(runID, serverID, "agent not connected — server skipped")
|
||||
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "skipped", "server_runs.$.finished_at": fin})
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run started on %s — %d step(s), workspace vantage-run-%s", serverID, len(steps), runID))
|
||||
|
||||
runEnv := map[string]string{}
|
||||
allSecrets := map[string]string{}
|
||||
serverFailed := false
|
||||
|
||||
for i, step := range steps {
|
||||
startStep(runID, serverID, i, "running")
|
||||
stepStart := time.Now()
|
||||
var res *pb.StepResult
|
||||
attempts := 0
|
||||
maxAttempts := 1
|
||||
@@ -197,7 +201,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
|
||||
// Write the step marker to the server-run log and remember the offset so
|
||||
// the UI can slice this step's output later.
|
||||
marker := fmt.Sprintf("\n===== step %d: %s =====\n", step.Order, step.Name)
|
||||
marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter)
|
||||
offset, _ := AppendMarker(runID, serverID, marker)
|
||||
logPath := ServerRunLogPath(runID, serverID)
|
||||
secretsSlice := secretValues(secretVals)
|
||||
@@ -205,6 +209,9 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
commandID := uuid.New().String()
|
||||
for attempts < maxAttempts {
|
||||
attempts++
|
||||
if attempts > 1 {
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("retry %d/%d after failure", attempts-1, maxAttempts-1))
|
||||
}
|
||||
// Open a fresh writer per attempt; the agent's eof closes it, and the
|
||||
// defensive Close below covers a missing result.
|
||||
_ = StepLogs.Open(commandID, logPath, secretsSlice)
|
||||
@@ -213,6 +220,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
Script: step.Script,
|
||||
Env: cmdEnv,
|
||||
TimeoutSeconds: 0,
|
||||
WorkspaceId: runID,
|
||||
})
|
||||
StepLogs.Close(commandID) // idempotent; no-op if eof already closed it
|
||||
if res != nil && res.ExitCode == 0 {
|
||||
@@ -229,7 +237,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
outEnv[k] = maskSecrets(v, allSecrets)
|
||||
}
|
||||
} else {
|
||||
_, _ = AppendMarker(runID, serverID, "[vantage] agent did not return a result\n")
|
||||
_, _ = AppendMarker(runID, serverID, "agent did not return a result")
|
||||
}
|
||||
|
||||
status := "success"
|
||||
@@ -238,25 +246,36 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
}
|
||||
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
|
||||
|
||||
dur := time.Since(stepStart).Round(time.Millisecond)
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("step %d/%d %s — exit %d, %d attempt(s), %s",
|
||||
step.Order+1, len(steps), status, exit, attempts, dur))
|
||||
|
||||
if exit != 0 {
|
||||
switch step.OnFailure {
|
||||
case "continue":
|
||||
// keep going
|
||||
_, _ = AppendMarker(runID, serverID, "on_failure=continue — proceeding to next step")
|
||||
default: // "stop" or exhausted "retry"
|
||||
serverFailed = true
|
||||
}
|
||||
if serverFailed {
|
||||
_, _ = AppendMarker(runID, serverID, "stopping run — remaining steps skipped")
|
||||
markRemainingSkipped(runID, serverID, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the agent to remove the run's working directory now that its steps are
|
||||
// done (success or failure). Best-effort; the OS reclaims temp dirs anyway.
|
||||
DispatchCleanupWorkspace(serverID, runID)
|
||||
|
||||
fin := time.Now()
|
||||
status := "success"
|
||||
if serverFailed {
|
||||
status = "failed"
|
||||
}
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run %s in %s — workspace removed",
|
||||
status, fin.Sub(now).Round(time.Millisecond)))
|
||||
// Persist only a masked copy of runEnv; the real (unmasked) runEnv was already
|
||||
// used above to build cmdEnv for each step and must never be written to the DB.
|
||||
maskedRunEnv := make(map[string]string, len(runEnv))
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,144 +1,442 @@
|
||||
"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({
|
||||
runId,
|
||||
serverId,
|
||||
status,
|
||||
}: {
|
||||
runId: string;
|
||||
serverId: string;
|
||||
status: string;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const running = status === "running";
|
||||
const cellGlyph: Record<CellKind, string> = {
|
||||
done: "✓",
|
||||
fail: "✕",
|
||||
run: "●",
|
||||
wait: "○",
|
||||
skip: "–",
|
||||
warn: "!",
|
||||
};
|
||||
|
||||
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();
|
||||
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, server }: { runId: string; server: ServerRun }) {
|
||||
const [text, setText] = useState("");
|
||||
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,
|
||||
});
|
||||
es.onmessage = (e) => setText((t) => t + e.data + "\n");
|
||||
es.addEventListener("done", () => es.close());
|
||||
es.onerror = () => es.close();
|
||||
return () => es.close();
|
||||
}
|
||||
api.getServerRunLog(runId, serverId)
|
||||
.then(setText)
|
||||
.catch(() => setText(""));
|
||||
}, [running, runId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<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 ? <LogLines text={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>
|
||||
);
|
||||
}
|
||||
|
||||
// LogLines renders the raw server-run log, parsing each line's leading UTC
|
||||
// timestamp ([2026-07-20T12:04:02.000Z]) and rendering it in the viewer's local
|
||||
// timezone. Event markers (===== …) are highlighted so the run's shape scans.
|
||||
const TS_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?(.*)$/;
|
||||
|
||||
function LogLines({ text }: { text: string }) {
|
||||
const lines = text.replace(/\n$/, "").split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const m = TS_RE.exec(line);
|
||||
if (!m) {
|
||||
return (
|
||||
<span key={i} className="block">
|
||||
{line || " "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const local = new Date(m[1]).toLocaleTimeString([], { hour12: false });
|
||||
const body = m[2];
|
||||
const isMarker = body.startsWith("=====");
|
||||
return (
|
||||
<span key={i} className="block">
|
||||
<span className="select-none text-[#565b74]" title={m[1]}>
|
||||
{local}{" "}
|
||||
</span>
|
||||
<span className={isMarker ? "font-semibold text-accent" : ""}>{body || " "}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
}
|
||||
// terminal: fetch the whole file once
|
||||
api
|
||||
.getServerRunLog(runId, serverId)
|
||||
.then(setText)
|
||||
.catch(() => setText(""));
|
||||
}, [running, runId, serverId]);
|
||||
return [...byOrder.entries()].map(([order, name]) => ({ order, name })).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
preRef.current?.scrollTo(0, preRef.current.scrollHeight);
|
||||
}, [text]);
|
||||
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="min-w-[240px] border-b border-r border-border px-4 py-3 text-left font-medium text-text-primary">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex-1 whitespace-nowrap">{sr.hostname}</span>
|
||||
<StatusPill status={sr.status} small />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
// ---- 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 { 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],
|
||||
queryFn: () => api.getRun(runId),
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
const { data: run, isLoading } = useQuery({
|
||||
queryKey: ["run", runId],
|
||||
queryFn: () => api.getRun(runId),
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
|
||||
const cancel = async () => {
|
||||
await api.cancelRun(runId);
|
||||
queryClient.invalidateQueries({ queryKey: ["run", runId] });
|
||||
};
|
||||
const running = run?.status === "running";
|
||||
|
||||
if (isLoading || !run) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
// tick the elapsed clock while running
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [running]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<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>
|
||||
{run.status === "running" && (
|
||||
<Button variant="danger" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
|
||||
|
||||
<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>
|
||||
// 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] });
|
||||
};
|
||||
|
||||
if (isLoading || !run) {
|
||||
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="mx-auto max-w-[1180px] p-8 pb-16">
|
||||
{/* identity bar */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* 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} · steps & 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
Reference in New Issue
Block a user