From c300693a5c453df44308af9a7f3aad0ec22a58ec Mon Sep 17 00:00:00 2001 From: Sam Bretz Date: Thu, 17 Sep 2026 14:48:01 -0700 Subject: [PATCH] fix: guide setup when manifest is missing --- internal/web/assets/app.css | 6 ++++++ internal/web/assets/app.js | 30 ++++++++++++++++++++++++++++++ internal/web/server.go | 13 ++++++++++++- internal/web/server_test.go | 11 +++++++++++ internal/web/view.go | 17 +++++++++++++---- 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 633ab5c..e699877 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -98,6 +98,10 @@ textarea { resize: vertical; line-height: 1.5; } .brand:hover { text-decoration: none; } .brand img { border-radius: 5px; } .side-root { color: var(--ink-3); font-size: 12.5px; padding: 2px 6px 0 34px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.setup-callout { margin: 12px 6px 0; padding: 11px 10px; border: 1px solid var(--needs); border-radius: 8px; background: var(--needs-soft); font-size: 12.5px; } +.setup-callout strong { display: block; font-size: 13px; color: var(--ink); } +.setup-callout p { margin: 4px 0 7px; color: var(--ink-2); line-height: 1.35; } +.setup-callout a { color: var(--ink); font-weight: 500; } .runs { flex: 1; overflow-y: auto; margin-top: 18px; } .group + .group { margin-top: 18px; } .group h2 { font-size: 12.5px; font-weight: 500; color: var(--ink-3); padding: 0 8px 4px; display: flex; justify-content: space-between; } @@ -383,6 +387,8 @@ kbd { font-family: var(--sans); font-size: 12px; font-weight: 500; border: 1px s .welcome { max-width: 520px; margin: 14vh auto 0; text-align: center; } .welcome h1 { font-size: 24px; margin-bottom: 8px; } .welcome p { color: var(--ink-2); margin-bottom: 20px; } +.setup-welcome code { display: inline-block; padding: 9px 12px; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--canvas); color: var(--ink); } +.setup-welcome .buttons { justify-content: center; } @media (max-width: 1180px) { .work { grid-template-columns: minmax(0, 1fr); } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index ca3d0f0..4e89dcf 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -56,6 +56,8 @@ const bytes = (n) => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} bytes`); const sha = (s) => (s ? s.slice(0, 7) : ''); const model = (m) => m || 'harness default'; + const SETUP_GUIDE = 'https://sam-bretz.github.io/envctl/first-workflow/'; + const INIT_COMMAND = 'envctl init --project '; // stageModel prefers the model a role actually ran over the one configured // for it: "harness default" never answered which model that turned out to // be, and an alias never showed the version behind it. @@ -476,6 +478,8 @@ function renderSide() { const st = ui.state; $('root').textContent = st ? st.root.replace(/^\/Users\/[^/]+/, '~') : ''; + const needsSetup = st && st.manifest && (!st.manifest.present || !st.manifest.valid); + $('new-run').disabled = !!needsSetup; const link = $('link'); link.className = 'link'; if (ui.link === 'ok' && st && !st.error) link.textContent = 'Live'; @@ -488,6 +492,13 @@ if (!items.length) continue; groups.push(h('section', { class: 'group' }, h('h2', {}, title, h('span', { text: String(items.length) })), items.map(runRow))); } + if (needsSetup) { + const invalid = st.manifest.present && !st.manifest.valid; + groups.unshift(h('div', { class: 'setup-callout' }, + h('strong', { text: invalid ? 'Fix envctl.yaml to start runs' : 'Add envctl.yaml to start runs' }), + h('p', { text: invalid ? 'The workflow configuration needs attention.' : 'This repository has no workflow configuration yet.' }), + h('a', { href: SETUP_GUIDE, target: '_blank', rel: 'noopener noreferrer', text: 'Read the setup guide' }))); + } if (!groups.length) groups.push(h('p', { class: 'empty-list', text: st ? 'No runs yet.' : 'Loading…' })); $('runs').replaceChildren(...groups); } @@ -533,12 +544,31 @@ const st = ui.state; if (st && st.error) return h('div', { class: 'welcome' }, h('h1', { text: 'The coordinator is offline' }), h('p', { text: 'Start it with envctl daemon serve, or run any envctl run command. This page reconnects on its own.' })); if (!st) return h('div', { class: 'welcome' }, h('p', { text: 'Connecting to envctl…' })); + if (st.manifest && !st.manifest.present) return setupWelcome('Add envctl.yaml to start runs', 'envctl needs a workflow configuration at the root of this repository.'); + if (st.manifest && !st.manifest.valid) return setupWelcome('Fix envctl.yaml to start runs', st.manifest.error || 'The workflow configuration could not be loaded.'); return h('div', { class: 'welcome' }, h('h1', { text: 'Start your first run' }), h('p', { text: 'Describe a change. Agents plan, build and check it in their own VM, and ask you before anything is published.' }), h('button', { type: 'button', class: 'btn btn-primary', text: 'New run', onclick: newRunDialog })); } + function setupWelcome(title, detail) { + return h('div', { class: 'welcome setup-welcome' }, + h('h1', { text: title }), + h('p', { text: detail }), + h('p', {}, h('code', { text: INIT_COMMAND })), + h('div', { class: 'buttons' }, + h('a', { class: 'btn btn-primary', href: SETUP_GUIDE, target: '_blank', rel: 'noopener noreferrer', text: 'Open setup guide' }), + h('button', { type: 'button', class: 'btn btn-quiet', text: 'Copy init command', onclick: async () => { + try { + await navigator.clipboard.writeText(INIT_COMMAND); + say('Copied the init command. Run it from this repository.'); + } catch { + say(`Run this from the repository: ${INIT_COMMAND}`); + } + } }))); + } + function renderHistoryNote(run, rev) { const note = $('history-note'); if (!viewingHistory(run)) return note.replaceChildren(); diff --git a/internal/web/server.go b/internal/web/server.go index 13d4a1b..1e066ce 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -319,7 +319,7 @@ func (s *Server) artifactPage(w http.ResponseWriter, r *http.Request) { func (s *Server) snapshot(ctx context.Context) State { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - st := State{Root: s.Root, Runs: []RunView{}, At: s.now()} + st := State{Root: s.Root, Manifest: s.manifest(), Runs: []RunView{}, At: s.now()} runs, err := s.API.List(ctx) if err != nil { st.Error = "The coordinator is not answering: " + err.Error() @@ -331,6 +331,17 @@ func (s *Server) snapshot(ctx context.Context) State { return st } +func (s *Server) manifest() ManifestView { + _, err := workflow.Load(s.Root) + if err == nil { + return ManifestView{Present: true, Valid: true} + } + if errors.Is(err, os.ErrNotExist) { + return ManifestView{} + } + return ManifestView{Present: true, Error: err.Error()} +} + func (s *Server) state(w http.ResponseWriter, r *http.Request) { writeJSON(w, s.snapshot(r.Context())) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 9555856..7326ea2 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -123,6 +123,17 @@ func TestOnlyThisMachineWithTheTokenCanUseTheDashboard(t *testing.T) { } } +func TestStateReportsMissingManifestForDashboardSetup(t *testing.T) { + s, _, _ := fixture(t) + if err := os.Remove(filepath.Join(s.Root, "envctl.yaml")); err != nil { + t.Fatal(err) + } + st := s.snapshot(context.Background()) + if st.Manifest.Present || st.Manifest.Valid { + t.Fatalf("missing manifest was not reported: %+v", st.Manifest) + } +} + func TestCreateSelectsTheWorkflowAndActionsKeepTheShownVersion(t *testing.T) { _, api, h := fixture(t) headers := map[string]string{"Content-Type": "application/json", "X-Envctl-Token": testToken} diff --git a/internal/web/view.go b/internal/web/view.go index bfe304a..8349920 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -12,10 +12,19 @@ import ( // every state the dashboard derives already computed. type State struct { - Root string `json:"root"` - Runs []RunView `json:"runs"` - Error string `json:"error,omitempty"` - At time.Time `json:"at"` + Root string `json:"root"` + Manifest ManifestView `json:"manifest"` + Runs []RunView `json:"runs"` + Error string `json:"error,omitempty"` + At time.Time `json:"at"` +} + +// ManifestView gives the dashboard enough context to make an unconfigured +// repository recoverable without exposing the workflow configuration itself. +type ManifestView struct { + Present bool `json:"present"` + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` } type RunView struct {