From f5db0b8997b459a8d008024ddc5c0ac9841d7374 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 12:30:56 -0700 Subject: [PATCH] feat(night-shift): Implement the repo-local Dash browser front door --- .codex/skills/qa-night-shift/SKILL.md | 11 +- docs/getting-started.md | 6 +- docs/providers-and-delivery.md | 9 +- docs/run-lifecycle.md | 6 +- src/night_shift/app.gleam | 11 +- src/night_shift/cli.gleam | 24 +- src/night_shift/dashboard.gleam | 271 +++---- src/night_shift/dashboard_view.gleam | 686 ++++++++++++++++++ src/night_shift/types.gleam | 4 +- test/night_shift_cli_config_test.gleam | 9 +- ...ight_shift_persistence_provider_test.gleam | 6 +- 11 files changed, 838 insertions(+), 205 deletions(-) create mode 100644 src/night_shift/dashboard_view.gleam diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 46078ca..503e8e0 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -131,8 +131,9 @@ Typical flow: resume attempt when the run was interrupted 10. use `night-shift resolve` or `night-shift resume` only if the run actually requires it -11. use `night-shift dash` when the QA pass needs browser-visible bootstrap, - SSE, command, audit, or raw artifact validation +11. use `night-shift dash` when the QA pass needs browser-visible guided init, + bootstrap, SSE, DAG/detail sync, command, audit, or raw artifact + validation For review-driven investigations, replace steps 3-4 with: @@ -145,8 +146,12 @@ In review-driven runs, pay attention to repo-state evidence: - the stored open-PR snapshot captured during planning - whether `status`, `report`, or the dashboard show repo-state drift +- whether `night-shift dash` guided init can move an uninitialized repo into + the normal initialized workspace without restarting Dash - whether `night-shift dash` bootstrap and SSE payloads stay aligned with the journal-backed repo-state snapshot +- whether the Dash workspace shows the pending or active DAG with synchronized + graph, list, and detail panes wired to the persisted task metadata - whether `night-shift report` shows the actionable/impacted subtree and replacement lineage, while the persisted `report.md` remains readable without live GitHub refresh @@ -180,7 +185,7 @@ In review-driven runs, pay attention to repo-state evidence: - whether `status`, `report`, and the dashboard agree on the confidence posture and its reasons - whether Dash command handlers mutate the same durable run state as the CLI, - without scraping command output + without scraping command output or inventing a separate control plane - whether `provenance` records the expected prompt paths, payload artifacts, verification evidence, worktree paths, and PR linkage - whether `doctor` classifies interrupted tasks as `safe_to_resume`, diff --git a/docs/getting-started.md b/docs/getting-started.md index 2402d22..5fec765 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -176,5 +176,9 @@ If you want a dry proof that the end-to-end harness is wired correctly: ```sh night-shift --demo -night-shift --demo --ui +night-shift dash ``` + +`night-shift --demo` keeps validating the fixture-backed CLI flow. Use +`night-shift dash` when you want the real browser workspace and its live audit +surface. diff --git a/docs/providers-and-delivery.md b/docs/providers-and-delivery.md index 2f3bd2b..86f9fe1 100644 --- a/docs/providers-and-delivery.md +++ b/docs/providers-and-delivery.md @@ -114,8 +114,10 @@ leave that disabled and still use the PR-body overlay. The local dashboard now uses `night-shift dash`. It binds to `127.0.0.1` and serves a Dash backend for: -- structured bootstrap state for the current repository -- SSE-first live updates +- structured bootstrap state for the current repository, including init state, + selected-run audit data, DAG metadata, repo-state drift, delivery context, + and runtime identity +- SSE-first live updates for the selected run and audit surface - browser command handlers for `init`, `plan`, `plan --from-reviews`, `resolve`, `start`, and `resume` - audit and raw artifact routes for reports, provenance, logs, payloads, and @@ -128,7 +130,8 @@ shelling out to `night-shift` subprocesses. `night-shift --demo` exercises a fixture-backed flow and prints a compact proof summary. The headless demo validates `plan`, `start`, `status`, and `report`. -The UI demo validates Dash bootstrap plus browser-command execution as well. +The UI demo validates Dash bootstrap plus browser-command execution as well, +but the supported human entrypoint is `night-shift dash`. Demo artifacts live under: diff --git a/docs/run-lifecycle.md b/docs/run-lifecycle.md index bd4197e..1606911 100644 --- a/docs/run-lifecycle.md +++ b/docs/run-lifecycle.md @@ -61,13 +61,17 @@ and the next action becomes `night-shift start`. night-shift doctor night-shift resume --explain night-shift resume -night-shift resume --run run-123 --ui +night-shift dash ``` Night Shift reloads the saved run, validates the saved environment, recovers in-flight tasks, and continues orchestration. It does not re-resolve provider or environment settings; it reuses what the run journal already saved. +Use `night-shift dash` when you want the browser workflow for the same durable +run state. Use `resume --explain` and `resume` when you prefer the CLI +recovery path. + `doctor` and `resume --explain` are the read-only recovery surfaces. They inspect the saved run, active lock, worktrees, logs, review drift, and interrupted task states, then classify each task as `safe_to_resume`, diff --git a/src/night_shift/app.gleam b/src/night_shift/app.gleam index 76decd9..4e7c535 100644 --- a/src/night_shift/app.gleam +++ b/src/night_shift/app.gleam @@ -134,20 +134,15 @@ fn run_initialized_command( ) Error(message) -> message }) - types.Start(run, False) -> io.println(start(repo_root, run, config)) + types.Start(run) -> io.println(start(repo_root, run, config)) types.Status(run) -> io.println(status(repo_root, run, config)) types.Report(run) -> io.println(report(repo_root, run, config)) types.Provenance(run, task_id, format) -> io.println(provenance(repo_root, run, task_id, format, config)) types.Doctor(run) -> io.println(doctor(repo_root, run, config)) types.Resolve(run) -> io.println(resolve(repo_root, run, config)) - types.Resume(run, False, False) -> - io.println(resume(repo_root, run, config)) - types.Resume(run, False, True) -> io.println(doctor(repo_root, run, config)) - types.Start(_, True) | types.Resume(_, True, False) -> - io.println( - "The `--ui` entrypoint was replaced by `night-shift dash`.", - ) + types.Resume(run, False) -> io.println(resume(repo_root, run, config)) + types.Resume(run, True) -> io.println(doctor(repo_root, run, config)) _ -> io.println("Unsupported command.") } } diff --git a/src/night_shift/cli.gleam b/src/night_shift/cli.gleam index 838da11..1afdc0b 100644 --- a/src/night_shift/cli.gleam +++ b/src/night_shift/cli.gleam @@ -31,7 +31,7 @@ pub fn usage() -> String { /// /// ```gleam /// > parse(["start", "--run", "latest"]) -/// Ok(types.Start(types.LatestRun, False)) +/// Ok(types.Start(types.LatestRun)) /// ``` /// /// ```gleam @@ -225,7 +225,7 @@ fn parse_init_flags( } fn parse_start(args: List(String)) -> Result(types.Command, String) { - parse_start_flags(args, types.LatestRun, False) + parse_start_flags(args, types.LatestRun) } fn parse_reset(args: List(String)) -> Result(types.Command, String) { @@ -248,35 +248,31 @@ fn parse_reset_flags( fn parse_start_flags( args: List(String), run: types.RunSelector, - ui_enabled: Bool, ) -> Result(types.Command, String) { case args { - [] -> Ok(types.Start(run, ui_enabled)) - ["--run", "latest", ..rest] -> - parse_start_flags(rest, types.LatestRun, ui_enabled) - ["--run", run_id, ..rest] -> - parse_start_flags(rest, types.RunId(run_id), ui_enabled) + [] -> Ok(types.Start(run)) + ["--run", "latest", ..rest] -> parse_start_flags(rest, types.LatestRun) + ["--run", run_id, ..rest] -> parse_start_flags(rest, types.RunId(run_id)) [flag, ..] -> Error("Unsupported start flag: " <> flag) } } fn parse_resume(args: List(String)) -> Result(types.Command, String) { - parse_resume_flags(args, types.LatestRun, False, False) + parse_resume_flags(args, types.LatestRun, False) } fn parse_resume_flags( args: List(String), run: types.RunSelector, - ui_enabled: Bool, explain_only: Bool, ) -> Result(types.Command, String) { case args { - [] -> Ok(types.Resume(run, ui_enabled, explain_only)) + [] -> Ok(types.Resume(run, explain_only)) ["--run", "latest", ..rest] -> - parse_resume_flags(rest, types.LatestRun, ui_enabled, explain_only) + parse_resume_flags(rest, types.LatestRun, explain_only) ["--run", run_id, ..rest] -> - parse_resume_flags(rest, types.RunId(run_id), ui_enabled, explain_only) - ["--explain", ..rest] -> parse_resume_flags(rest, run, ui_enabled, True) + parse_resume_flags(rest, types.RunId(run_id), explain_only) + ["--explain", ..rest] -> parse_resume_flags(rest, run, True) [flag, ..] -> Error("Unsupported flag: " <> flag) } } diff --git a/src/night_shift/dashboard.gleam b/src/night_shift/dashboard.gleam index 8f94529..38a1db3 100644 --- a/src/night_shift/dashboard.gleam +++ b/src/night_shift/dashboard.gleam @@ -9,6 +9,7 @@ import gleam/result import gleam/string import night_shift/agent_config import night_shift/config +import night_shift/dashboard_view import night_shift/domain/confidence import night_shift/domain/decisions as decision_domain import night_shift/domain/provenance @@ -24,8 +25,8 @@ import night_shift/types import night_shift/usecase/init as init_usecase import night_shift/usecase/plan as plan_usecase import night_shift/usecase/resolve as resolve_usecase -import night_shift/usecase/resume as resume_usecase import night_shift/usecase/result as workflow +import night_shift/usecase/resume as resume_usecase import night_shift/usecase/start as start_usecase import simplifile @@ -120,52 +121,7 @@ pub fn start_resume_session( } pub fn index_html(_initial_run_id: String) -> String { - "\n" - <> "\n" - <> "\n" - <> " \n" - <> " \n" - <> " Night Shift Dash\n" - <> " \n" - <> "\n" - <> "\n" - <> "
\n" - <> "
\n" - <> "

Night Shift Dash

\n" - <> "

This backend now serves a structured bootstrap plus SSE-first state updates.

\n" - <> "
\n" - <> "
\n" - <> "

Bootstrap

\n" - <> "
Loading bootstrap...
\n" - <> "
\n" - <> "
\n" - <> "

Live State

\n" - <> "
Connecting...
\n" - <> "
\n" - <> "
\n" - <> " \n" - <> "\n" - <> "\n" + dashboard_view.index_html() } pub fn bootstrap_json( @@ -233,14 +189,12 @@ pub fn command_json( "start" -> start_command_json(repo_root, body) "resume" -> resume_command_json(repo_root, body) _ -> - Error( - command_error_json( - repo_root, - command, - "Unsupported dash command: " <> command, - None, - ), - ) + Error(command_error_json( + repo_root, + command, + "Unsupported dash command: " <> command, + None, + )) } } @@ -279,14 +233,18 @@ fn bootstrap_payload( let runs = list_runs_or_empty(repo_root) let active_run_id = active_run_id_or_none(repo_root) let latest_run_id = latest_run_id(runs) - let selected_run_id = choose_selected_run_id(requested_run_id, active_run_id, runs) + let selected_run_id = + choose_selected_run_id(requested_run_id, active_run_id, runs) json.object([ #("mode", json.string("dash")), #("repo_root", json.string(repo_root)), #("initialized", json.bool(configuration.initialized)), #("config_path", json.string(project.config_path(repo_root))), - #("worktree_setup_path", json.string(project.worktree_setup_path(repo_root))), + #( + "worktree_setup_path", + json.string(project.worktree_setup_path(repo_root)), + ), #( "config_error", json.nullable(from: repo_config_error(configuration), of: json.string), @@ -379,14 +337,8 @@ fn run_payload_json( #("state_path", json.string(run.state_path)), #("events_path", json.string(run.events_path)), #("lock_path", json.string(run.lock_path)), - #( - "planning_agent", - agent_json(run.planning_agent), - ), - #( - "execution_agent", - agent_json(run.execution_agent), - ), + #("planning_agent", agent_json(run.planning_agent)), + #("execution_agent", agent_json(run.execution_agent)), #("environment_name", json.string(run.environment_name)), #("max_workers", json.int(run.max_workers)), #("planning_dirty", json.bool(run.planning_dirty)), @@ -395,17 +347,15 @@ fn run_payload_json( #("updated_at", json.string(run.updated_at)), #( "planning_provenance", - json.nullable( - from: run.planning_provenance, - of: fn(value) { json.string(types.planning_provenance_label(value)) }, - ), + json.nullable(from: run.planning_provenance, of: fn(value) { + json.string(types.planning_provenance_label(value)) + }), ), #( "notes_source", - json.nullable( - from: run.notes_source, - of: fn(value) { json.string(types.notes_source_label(value)) }, - ), + json.nullable(from: run.notes_source, of: fn(value) { + json.string(types.notes_source_label(value)) + }), ), #( "confidence_posture", @@ -417,14 +367,14 @@ fn run_payload_json( "confidence_reasons", json.array(confidence_assessment.reasons, json.string), ), - #( - "decision_requests", - json.array(pending_decisions, pending_decision_json), - ), + #("decision_requests", json.array(pending_decisions, pending_decision_json)), #("recorded_decisions", json.array(run.decisions, recorded_decision_json)), #( "repo_state", - json.nullable(from: review_projection, of: review_projection_repo_state_json), + json.nullable( + from: review_projection, + of: review_projection_repo_state_json, + ), ), #( "review_lineage", @@ -530,9 +480,10 @@ fn plan_command_json( command_error_json(repo_root, command_name, decode_message(message), None) }), ) - use configuration <- result.try( - require_initialized_config(repo_root, command_name) - ) + use configuration <- result.try(require_initialized_config( + repo_root, + command_name, + )) use agent_overrides <- result.try( agent_overrides_from_strings( request.profile, @@ -545,7 +496,10 @@ fn plan_command_json( }), ) use planning_agent <- result.try( - agent_config.resolve_plan_agent(extract_config(configuration), agent_overrides) + agent_config.resolve_plan_agent( + extract_config(configuration), + agent_overrides, + ) |> result.map_error(fn(message) { command_error_json(repo_root, command_name, message, None) }), @@ -591,11 +545,9 @@ fn resolve_command_json( let selector = run_selector(request.run_id) case - resolve_usecase.execute( - repo_root, - selector, - fn(run, tasks) { collect_dash_decisions(run, tasks, request.answers) }, - ) + resolve_usecase.execute(repo_root, selector, fn(run, tasks) { + collect_dash_decisions(run, tasks, request.answers) + }) { Ok(view) -> Ok(command_success_json( @@ -615,10 +567,7 @@ fn resolve_command_json( } } -fn start_command_json( - repo_root: String, - body: String, -) -> Result(String, String) { +fn start_command_json(repo_root: String, body: String) -> Result(String, String) { use request <- result.try( decode_or_default(body, run_request_decoder(), RawRunRequest(run_id: "")) |> result.map_error(fn(message) { @@ -627,7 +576,13 @@ fn start_command_json( ) use configuration <- result.try(require_initialized_config(repo_root, "start")) - case start_usecase.execute(repo_root, run_selector(request.run_id), extract_config(configuration)) { + case + start_usecase.execute( + repo_root, + run_selector(request.run_id), + extract_config(configuration), + ) + { Ok(view) -> Ok(command_success_json( repo_root, @@ -656,15 +611,18 @@ fn resume_command_json( command_error_json(repo_root, "resume", decode_message(message), None) }), ) - use configuration <- result.try( - require_initialized_config(repo_root, "resume") - ) - - case resume_usecase.execute( + use configuration <- result.try(require_initialized_config( repo_root, - run_selector(request.run_id), - extract_config(configuration), - ) { + "resume", + )) + + case + resume_usecase.execute( + repo_root, + run_selector(request.run_id), + extract_config(configuration), + ) + { Ok(view) -> Ok(command_success_json( repo_root, @@ -759,7 +717,8 @@ fn task_json( let relevant_events = events |> list.filter(fn(event) { event.task_id == Some(task.id) }) - let delivered_link = delivered_pr_link(task, run.handoff_states, relevant_events) + let delivered_link = + delivered_pr_link(task, run.handoff_states, relevant_events) json.object([ #("id", json.string(task.id)), @@ -779,10 +738,7 @@ fn task_json( decision_request_json, ), ), - #( - "superseded_pr_numbers", - json.array(task.superseded_pr_numbers, json.int), - ), + #("superseded_pr_numbers", json.array(task.superseded_pr_numbers, json.int)), #("task_kind", json.string(types.task_kind_to_string(task.kind))), #( "execution_mode", @@ -857,8 +813,8 @@ fn task_dag_json(tasks: List(types.Task)) -> json.Json { "ready_task_ids", json.array( tasks - |> list.filter(fn(task) { task.state == types.Ready }) - |> list.map(fn(task) { task.id }), + |> list.filter(fn(task) { task.state == types.Ready }) + |> list.map(fn(task) { task.id }), json.string, ), ), @@ -866,8 +822,8 @@ fn task_dag_json(tasks: List(types.Task)) -> json.Json { "running_task_ids", json.array( tasks - |> list.filter(fn(task) { task.state == types.Running }) - |> list.map(fn(task) { task.id }), + |> list.filter(fn(task) { task.state == types.Running }) + |> list.map(fn(task) { task.id }), json.string, ), ), @@ -875,10 +831,10 @@ fn task_dag_json(tasks: List(types.Task)) -> json.Json { "blocked_task_ids", json.array( tasks - |> list.filter(fn(task) { - task.state == types.Blocked || task.state == types.ManualAttention - }) - |> list.map(fn(task) { task.id }), + |> list.filter(fn(task) { + task.state == types.Blocked || task.state == types.ManualAttention + }) + |> list.map(fn(task) { task.id }), json.string, ), ), @@ -886,8 +842,8 @@ fn task_dag_json(tasks: List(types.Task)) -> json.Json { "completed_task_ids", json.array( tasks - |> list.filter(fn(task) { task.state == types.Completed }) - |> list.map(fn(task) { task.id }), + |> list.filter(fn(task) { task.state == types.Completed }) + |> list.map(fn(task) { task.id }), json.string, ), ), @@ -920,10 +876,7 @@ fn review_projection_repo_state_json( let summary = projection.repo_state json.object([ - #( - "captured_open_pr_count", - json.int(summary.captured_open_pr_count), - ), + #("captured_open_pr_count", json.int(summary.captured_open_pr_count)), #( "captured_actionable_pr_count", json.int(summary.captured_actionable_pr_count), @@ -1019,18 +972,9 @@ fn handoff_state_json(state: types.TaskHandoffState) -> json.Json { json.object([ #("task_id", json.string(state.task_id)), #("delivered_pr_number", json.string(state.delivered_pr_number)), - #( - "last_delivered_commit_sha", - json.string(state.last_delivered_commit_sha), - ), - #( - "last_handoff_files", - json.array(state.last_handoff_files, json.string), - ), - #( - "last_verification_digest", - json.string(state.last_verification_digest), - ), + #("last_delivered_commit_sha", json.string(state.last_delivered_commit_sha)), + #("last_handoff_files", json.array(state.last_handoff_files, json.string)), + #("last_verification_digest", json.string(state.last_verification_digest)), #("last_risks", json.array(state.last_risks, json.string)), #("last_handoff_updated_at", json.string(state.last_handoff_updated_at)), #("body_region_present", json.bool(state.body_region_present)), @@ -1107,13 +1051,10 @@ fn recorded_decision_json(decision: types.RecordedDecision) -> json.Json { fn repo_configuration(repo_root: String) -> RepoConfiguration { let config_path = project.config_path(repo_root) let initialized = file_exists(config_path) - RepoConfiguration( - initialized: initialized, - config_result: case initialized { - True -> config.load(config_path) - False -> Ok(types.default_config()) - }, - ) + RepoConfiguration(initialized: initialized, config_result: case initialized { + True -> config.load(config_path) + False -> Ok(types.default_config()) + }) } fn require_initialized_config( @@ -1123,14 +1064,12 @@ fn require_initialized_config( let configuration = repo_configuration(repo_root) case configuration.initialized, configuration.config_result { False, _ -> - Error( - command_error_json( - repo_root, - command, - "Night Shift is not initialized for this repository. Run `night-shift init` or POST `/api/commands/init` first.", - None, - ), - ) + Error(command_error_json( + repo_root, + command, + "Night Shift is not initialized for this repository. Run `night-shift init` or POST `/api/commands/init` first.", + None, + )) True, Ok(_) -> Ok(configuration) True, Error(message) -> Error(command_error_json(repo_root, command, message, None)) @@ -1273,7 +1212,11 @@ fn init_request_decoder() -> decode.Decoder(RawInitRequest) { use provider <- decode.optional_field("provider", "", decode.string) use model <- decode.optional_field("model", "", decode.string) use reasoning <- decode.optional_field("reasoning", "", decode.string) - use generate_setup <- decode.optional_field("generate_setup", False, decode.bool) + use generate_setup <- decode.optional_field( + "generate_setup", + False, + decode.bool, + ) decode.success(RawInitRequest( profile: profile, provider: provider, @@ -1351,7 +1294,8 @@ fn decision_answer_decoder() -> decode.Decoder(RawDecisionAnswer) { fn decode_message(error: json.DecodeError) -> String { case error { - json.UnexpectedEndOfInput -> "Invalid JSON payload: unexpected end of input." + json.UnexpectedEndOfInput -> + "Invalid JSON payload: unexpected end of input." json.UnexpectedByte(byte) -> "Invalid JSON payload: unexpected byte `" <> byte <> "`." json.UnexpectedSequence(sequence) -> @@ -1449,8 +1393,7 @@ fn collect_dash_decisions( case prompts { [] -> Error("No unresolved manual-attention decisions were found for this run.") - _ -> - collect_dash_decisions_loop(prompts, answers, [], []) + _ -> collect_dash_decisions_loop(prompts, answers, [], []) } } @@ -1497,13 +1440,12 @@ fn resolve_dash_answer( use answer <- result.try(find_decision_answer(answers, request.key)) case request.options { [] -> - Ok(#( - answer, - case request.allow_freeform { + Ok( + #(answer, case request.allow_freeform { True -> None False -> Some(decision_contract_warning_event(task, request)) - }, - )) + }), + ) options -> case list.any(options, fn(option) { option.label == answer }) { True -> Ok(#(answer, None)) @@ -1548,10 +1490,7 @@ fn decision_contract_warning_event( fn resolve_message(view: workflow.ResolveResult) -> String { case view.summary { Some(summary) -> summary - None -> - "Resolved run " - <> view.run.run_id - <> "." + None -> "Resolved run " <> view.run.run_id <> "." } } @@ -1694,9 +1633,7 @@ fn runtime_manifest_path( } } -fn runtime_handoff_path( - context: Option(types.RuntimeContext), -) -> Option(String) { +fn runtime_handoff_path(context: Option(types.RuntimeContext)) -> Option(String) { case context { Some(value) -> existing_file(value.handoff_path) None -> None @@ -1711,7 +1648,8 @@ fn runtime_env_path(context: Option(types.RuntimeContext)) -> Option(String) { } fn existing_files(paths: List(String)) -> List(String) { - paths |> list.filter_map(fn(path) { + paths + |> list.filter_map(fn(path) { case existing_file(path) { Some(value) -> Ok(value) None -> Error(Nil) @@ -1751,10 +1689,9 @@ fn agent_json(agent: types.ResolvedAgentConfig) -> json.Json { #("model", json.nullable(from: agent.model, of: json.string)), #( "reasoning", - json.nullable( - from: agent.reasoning, - of: fn(level) { json.string(types.reasoning_to_string(level)) }, - ), + json.nullable(from: agent.reasoning, of: fn(level) { + json.string(types.reasoning_to_string(level)) + }), ), ]) } diff --git a/src/night_shift/dashboard_view.gleam b/src/night_shift/dashboard_view.gleam new file mode 100644 index 0000000..d5a4d08 --- /dev/null +++ b/src/night_shift/dashboard_view.gleam @@ -0,0 +1,686 @@ +import gleam/string + +pub fn index_html() -> String { + [ + "", + "", + "", + " ", + " ", + " Night Shift Dash", + " ", + "", + "", + "
", + "
", + "
", + "
", + "

", + "

Night Shift Dash

", + "

A localhost-only front door for bootstrap, planning, execution, recovery, and audit. The browser consumes the same durable journal and repo-state artifacts as the CLI, which is a mildly radical way to avoid inventing yet another control plane.

", + "
", + "
", + "
", + "
", + "
", + "
", + " ", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + " ", + "", + "", + ] + |> string.join(with: "\n") +} diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index b11e4a0..90c688f 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -688,7 +688,7 @@ pub fn default_config() -> Config { /// Parsed CLI commands for the operator-facing executable. pub type Command { Dash - Start(run: RunSelector, ui_enabled: Bool) + Start(run: RunSelector) Init(agent_overrides: AgentOverrides, generate_setup: Bool, assume_yes: Bool) Reset(assume_yes: Bool, force: Bool) Plan( @@ -706,7 +706,7 @@ pub type Command { ) Doctor(run: RunSelector) Resolve(run: RunSelector) - Resume(run: RunSelector, ui_enabled: Bool, explain_only: Bool) + Resume(run: RunSelector, explain_only: Bool) Demo(ui_enabled: Bool) Help } diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index 5b1c16b..2f81e68 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -7,7 +7,7 @@ import night_shift/types import night_shift/worktree_setup pub fn parse_start_command_test() { - let assert Ok(types.Start(types.RunId("run-123"), False)) = + let assert Ok(types.Start(types.RunId("run-123"))) = cli.parse(["start", "--run", "run-123"]) } @@ -76,7 +76,7 @@ pub fn parse_start_rejects_ui_test() { } pub fn parse_start_command_without_brief_test() { - let assert Ok(types.Start(types.LatestRun, False)) = cli.parse(["start"]) + let assert Ok(types.Start(types.LatestRun)) = cli.parse(["start"]) } pub fn parse_plan_requires_notes_test() { @@ -89,13 +89,12 @@ pub fn parse_resolve_defaults_to_latest_test() { } pub fn parse_resume_rejects_ui_test() { - let assert Error(message) = - cli.parse(["resume", "--run", "run-123", "--ui"]) + let assert Error(message) = cli.parse(["resume", "--run", "run-123", "--ui"]) assert message == "Unsupported flag: --ui" } pub fn parse_resume_explain_command_test() { - let assert Ok(types.Resume(types.LatestRun, False, True)) = + let assert Ok(types.Resume(types.LatestRun, True)) = cli.parse(["resume", "--explain"]) } diff --git a/test/night_shift_persistence_provider_test.gleam b/test/night_shift_persistence_provider_test.gleam index 8732332..93e569b 100644 --- a/test/night_shift_persistence_provider_test.gleam +++ b/test/night_shift_persistence_provider_test.gleam @@ -257,11 +257,15 @@ pub fn dashboard_server_serves_run_data_test() { system.sleep(100) let assert Ok(index_html) = dashboard.http_get(session.url) - let assert Ok(runs_payload) = dashboard.http_get(session.url <> "/api/bootstrap") + let assert Ok(runs_payload) = + dashboard.http_get(session.url <> "/api/bootstrap") let assert Ok(run_payload) = dashboard.http_get(session.url <> "/api/audit?run_id=" <> run.run_id) assert string.contains(does: index_html, contain: "Night Shift Dash") + assert string.contains(does: index_html, contain: "Task DAG") + assert string.contains(does: index_html, contain: "Command Center") + assert string.contains(does: index_html, contain: "Plan from reviews") assert string.contains(does: runs_payload, contain: run.run_id) assert string.contains( does: run_payload,