diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 1532e0c..46078ca 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -131,6 +131,8 @@ 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 For review-driven investigations, replace steps 3-4 with: @@ -143,6 +145,8 @@ 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` bootstrap and SSE payloads stay aligned with the + journal-backed repo-state snapshot - whether `night-shift report` shows the actionable/impacted subtree and replacement lineage, while the persisted `report.md` remains readable without live GitHub refresh @@ -175,6 +179,8 @@ In review-driven runs, pay attention to repo-state evidence: failures with usable artifact paths - 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 - 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/README.md b/README.md index 81d02ab..fb68de1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Night Shift already has working support for: - provider adapters for Codex CLI and Cursor Agent - local verification before pull request delivery - review-loop ingestion for open Night Shift pull requests -- a local monitor-only dashboard via `start --ui` and `resume --ui` +- a localhost-only Dash control surface via `night-shift dash` The current operator flow is: @@ -126,10 +126,10 @@ night-shift resume --explain night-shift resume ``` -If you want the local dashboard while a run is active: +If you want the local dashboard: ```sh -night-shift start --ui +night-shift dash ``` ## Source Development diff --git a/docs/getting-started.md b/docs/getting-started.md index f625bed..2402d22 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -100,11 +100,11 @@ Kick off the most recent pending run: night-shift start ``` -You can also target a specific run or open the local dashboard: +You can also target a specific run or open Dash: ```sh night-shift start --run run-123 -night-shift start --ui +night-shift dash ``` `start` is execution-only. It uses the execution agent, environment, and brief @@ -154,7 +154,6 @@ If a run was interrupted, resume from the saved journal: night-shift doctor night-shift resume --explain night-shift resume -night-shift resume --ui ``` `doctor` is the dry recovery pass. It classifies each task as diff --git a/docs/providers-and-delivery.md b/docs/providers-and-delivery.md index 717d5ee..2f3bd2b 100644 --- a/docs/providers-and-delivery.md +++ b/docs/providers-and-delivery.md @@ -111,23 +111,24 @@ leave that disabled and still use the PR-body overlay. ## Dashboard -The local dashboard is intentionally narrow in scope. It binds to `127.0.0.1`, -prefers port `8787`, and serves a monitor-only UI for: +The local dashboard now uses `night-shift dash`. It binds to `127.0.0.1` and +serves a Dash backend for: -- run history -- summary metadata for the selected run -- repo-state summary for review-driven runs, including snapshot time and drift -- task status -- event timeline -- report content +- structured bootstrap state for the current repository +- SSE-first live updates +- browser command handlers for `init`, `plan`, `plan --from-reviews`, + `resolve`, `start`, and `resume` +- audit and raw artifact routes for reports, provenance, logs, payloads, and + runtime identity files -There are no browser-side mutation controls. +The browser surface reuses the same in-process usecases as the CLI rather than +shelling out to `night-shift` subprocesses. ## Demo Mode `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 the local dashboard payload as well. +The UI demo validates Dash bootstrap plus browser-command execution as well. Demo artifacts live under: diff --git a/docs/run-lifecycle.md b/docs/run-lifecycle.md index 3d6842d..bd4197e 100644 --- a/docs/run-lifecycle.md +++ b/docs/run-lifecycle.md @@ -151,22 +151,15 @@ attention with both the original and repair artifacts recorded. ## Dashboard -The dashboard is monitor-only: +Dash is the human-first local control surface: ```sh -night-shift start --ui -night-shift resume --ui +night-shift dash ``` -Night Shift binds to `127.0.0.1`, prefers port `8787`, and serves: +Night Shift binds Dash to `127.0.0.1` for the current repository and serves: -- run history for the current repository -- run summary metadata -- repo-state summary for review-driven runs, including open PR counts and drift -- confidence posture and provenance path -- task status -- event timeline -- report content - -There are no browser-side controls for starting or resuming runs in this -version. +- structured bootstrap state for initialization, runs, task DAG metadata, repo-state drift, confidence, runtime identities, report, and provenance references +- SSE-first live updates for the selected repository state +- browser command endpoints for `init`, `plan`, `plan --from-reviews`, `resolve`, `start`, and `resume` +- raw artifact and audit routes for reports, provenance, logs, payloads, and runtime handoff files diff --git a/src/night_shift/app.gleam b/src/night_shift/app.gleam index 1a56d8e..76decd9 100644 --- a/src/night_shift/app.gleam +++ b/src/night_shift/app.gleam @@ -49,6 +49,11 @@ pub fn run(command: types.Command) -> Nil { <> "\n" <> crate_summary(types.default_config()), ) + types.Dash -> + case dashboard_session.start(repo_root) { + Ok(Nil) -> Nil + Error(message) -> io.println(message) + } types.Demo(ui) -> case demo.run(ui) { Ok(summary) -> io.println(summary) @@ -130,7 +135,6 @@ fn run_initialized_command( Error(message) -> message }) types.Start(run, False) -> io.println(start(repo_root, run, config)) - types.Start(run, True) -> start_with_ui(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) -> @@ -139,8 +143,11 @@ fn run_initialized_command( 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, True, False) -> resume_with_ui(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`.", + ) _ -> io.println("Unsupported command.") } } @@ -330,28 +337,6 @@ fn reset(repo_root: String, assume_yes: Bool, force: Bool) -> String { } } -fn start_with_ui( - repo_root: String, - selector: types.RunSelector, - config: types.Config, -) -> Nil { - case dashboard_session.start(repo_root, selector, config) { - Ok(Nil) -> Nil - Error(message) -> io.println(message) - } -} - -fn resume_with_ui( - repo_root: String, - run: types.RunSelector, - config: types.Config, -) -> Nil { - case dashboard_session.resume(repo_root, run, config) { - Ok(Nil) -> Nil - Error(message) -> io.println(message) - } -} - fn review_profile_deprecation_fragment(review_profile: String) -> String { case review_profile { "" -> "" diff --git a/src/night_shift/cli.gleam b/src/night_shift/cli.gleam index f9cc45b..838da11 100644 --- a/src/night_shift/cli.gleam +++ b/src/night_shift/cli.gleam @@ -10,18 +10,19 @@ pub fn usage() -> String { <> "\n" <> "Commands:\n" <> " --demo [--ui]\n" + <> " dash\n" <> " init [--profile ] [--provider ] [--model ] [--reasoning ] [--yes] [--generate-setup]\n" <> " Prompts interactively for provider, model, and initial worktree setup when those answers are not supplied.\n" <> " reset [--yes] [--force]\n" <> " plan --notes [--doc ] [--profile ] [--provider ] [--model ] [--reasoning ]\n" <> " plan --from-reviews [--notes ] [--doc ] [--profile ] [--provider ] [--model ] [--reasoning ]\n" - <> " start [--run |latest] [--ui]\n" + <> " start [--run |latest]\n" <> " status [--run |latest]\n" <> " report [--run |latest]\n" <> " provenance [--run |latest] [--task ] [--format ]\n" <> " doctor [--run |latest]\n" <> " resolve [--run |latest]\n" - <> " resume [--run |latest] [--ui|--explain]\n" + <> " resume [--run |latest] [--explain]\n" } /// Parse raw command-line arguments into a `Command`. @@ -44,6 +45,7 @@ pub fn parse(args: List(String)) -> Result(types.Command, String) { case args { [] -> Ok(types.Help) ["help", ..] -> Ok(types.Help) + ["dash"] -> Ok(types.Dash) ["init", ..rest] -> parse_init(rest) ["reset", ..rest] -> parse_reset(rest) ["plan", ..rest] -> parse_plan(rest) @@ -254,7 +256,6 @@ fn parse_start_flags( parse_start_flags(rest, types.LatestRun, ui_enabled) ["--run", run_id, ..rest] -> parse_start_flags(rest, types.RunId(run_id), ui_enabled) - ["--ui", ..rest] -> parse_start_flags(rest, run, True) [flag, ..] -> Error("Unsupported start flag: " <> flag) } } @@ -270,16 +271,11 @@ fn parse_resume_flags( explain_only: Bool, ) -> Result(types.Command, String) { case args { - [] -> - case ui_enabled && explain_only { - True -> Error("`resume --explain` cannot be combined with `--ui`.") - False -> Ok(types.Resume(run, ui_enabled, explain_only)) - } + [] -> Ok(types.Resume(run, ui_enabled, explain_only)) ["--run", "latest", ..rest] -> parse_resume_flags(rest, types.LatestRun, ui_enabled, explain_only) ["--run", run_id, ..rest] -> parse_resume_flags(rest, types.RunId(run_id), ui_enabled, explain_only) - ["--ui", ..rest] -> parse_resume_flags(rest, run, True, explain_only) ["--explain", ..rest] -> parse_resume_flags(rest, run, ui_enabled, True) [flag, ..] -> Error("Unsupported flag: " <> flag) } diff --git a/src/night_shift/dashboard.gleam b/src/night_shift/dashboard.gleam index 03cbd2b..8f94529 100644 --- a/src/night_shift/dashboard.gleam +++ b/src/night_shift/dashboard.gleam @@ -1,264 +1,374 @@ -//// Minimal local dashboard surface for inspecting Night Shift runs. +//// Repo-local Dash state and command surface for Night Shift. +import filepath +import gleam/dynamic/decode import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/result +import gleam/string +import night_shift/agent_config import night_shift/config import night_shift/domain/confidence +import night_shift/domain/decisions as decision_domain import night_shift/domain/provenance +import night_shift/domain/repo_state import night_shift/domain/review_run_projection import night_shift/journal import night_shift/project +import night_shift/provider_models import night_shift/repo_state_runtime import night_shift/report +import night_shift/system 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/start as start_usecase +import simplifile -/// A running local dashboard session. pub type Session { Session(url: String, handle: String) } -/// Start a read-only dashboard session for an existing run. -@external(erlang, "night_shift_dashboard_server", "start_view_session") -pub fn start_view_session( - repo_root: String, - initial_run_id: String, -) -> Result(Session, String) +type RepoConfiguration { + RepoConfiguration( + initialized: Bool, + config_result: Result(types.Config, String), + ) +} -/// Start a dashboard session that owns a live `start` invocation. -@external(erlang, "night_shift_dashboard_server", "start_start_session") -pub fn start_start_session( - repo_root: String, - initial_run_id: String, - run: types.RunRecord, - config: types.Config, -) -> Result(Session, String) +type RawInitRequest { + RawInitRequest( + profile: String, + provider: String, + model: String, + reasoning: String, + generate_setup: Bool, + ) +} -/// Start a dashboard session that owns a live `resume` invocation. -@external(erlang, "night_shift_dashboard_server", "start_resume_session") -pub fn start_resume_session( - repo_root: String, - initial_run_id: String, - run: types.RunRecord, - config: types.Config, -) -> Result(Session, String) +type RawPlanRequest { + RawPlanRequest( + run_id: String, + notes: String, + doc_path: String, + profile: String, + provider: String, + model: String, + reasoning: String, + ) +} + +type RawRunRequest { + RawRunRequest(run_id: String) +} + +type RawResolveRequest { + RawResolveRequest(run_id: String, answers: List(RawDecisionAnswer)) +} + +type RawDecisionAnswer { + RawDecisionAnswer(key: String, answer: String) +} + +type DeliveredPrLink { + DeliveredPrLink( + number: String, + url: Option(String), + handoff_state: Option(types.TaskHandoffState), + ) +} + +@external(erlang, "night_shift_dashboard_server", "start_session") +pub fn start_session(repo_root: String) -> Result(Session, String) -/// Stop a running dashboard session. @external(erlang, "night_shift_dashboard_server", "stop_session") pub fn stop_session(session: Session) -> Nil -/// Fetch a dashboard URL from the local server. @external(erlang, "night_shift_dashboard_server", "http_get") pub fn http_get(url: String) -> Result(String, String) -/// Render the self-contained dashboard HTML shell. -pub fn index_html(initial_run_id: String) -> String { - let initial_run_json = json.string(initial_run_id) |> json.to_string +@external(erlang, "night_shift_dashboard_server", "http_post") +pub fn http_post(url: String, body: String) -> Result(String, String) + +pub fn start_view_session( + repo_root: String, + _initial_run_id: String, +) -> Result(Session, String) { + start_session(repo_root) +} + +pub fn start_start_session( + repo_root: String, + _initial_run_id: String, + _run: types.RunRecord, + _config: types.Config, +) -> Result(Session, String) { + start_session(repo_root) +} + +pub fn start_resume_session( + repo_root: String, + _initial_run_id: String, + _run: types.RunRecord, + _config: types.Config, +) -> Result(Session, String) { + start_session(repo_root) +} +pub fn index_html(_initial_run_id: String) -> String { "\n" <> "\n" <> "\n" <> " \n" <> " \n" - <> " Night Shift Dashboard\n" + <> " Night Shift Dash\n" <> " \n" <> "\n" <> "\n" - <> "
\n" - <> "
\n" - <> "

Night Shift Dashboard

\n" - <> "

Loading run history...

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

Night Shift Dash

\n" + <> "

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

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

Tasks

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

    Timeline

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

      Report

      \n" - <> "
      Loading report...
      \n" - <> "
      \n" - <> "
      \n" + <> "
      \n" + <> "

      Bootstrap

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

      Live State

      \n" + <> "
      Connecting...
      \n" + <> "
      \n" + <> " \n" <> " \n" <> "\n" <> "\n" } -/// Encode the repository's run history as dashboard JSON. -pub fn runs_json(repo_root: String) -> Result(String, String) { - use runs <- result.try(journal.list_runs(repo_root)) +pub fn bootstrap_json( + repo_root: String, + requested_run_id: String, +) -> Result(String, String) { Ok( - runs - |> list.map(run_summary_json) - |> json.array(identity) + bootstrap_payload(repo_root, run_id_option(requested_run_id)) |> json.to_string, ) } -/// Encode one run, its events, and its report as dashboard JSON. -pub fn run_json(repo_root: String, run_id: String) -> Result(String, String) { - use #(run, events) <- result.try(journal.load(repo_root, types.RunId(run_id))) - let repo_state_view = load_repo_state_view(run) - let review_projection = - review_run_projection.build(run, events, repo_state_view) - let confidence_assessment = confidence.assess(run, events, repo_state_view) - let rendered_report = report.render_live(run, events, repo_state_view) +pub fn audit_json( + repo_root: String, + requested_run_id: String, +) -> Result(String, String) { Ok( json.object([ - #("run", run_detail_json(run, review_projection, confidence_assessment)), - #("events", json.array(events, event_json)), - #("report", json.string(rendered_report)), + #( + "run", + json.nullable( + from: selected_run_payload(repo_root, run_id_option(requested_run_id)), + of: identity_json, + ), + ), ]) |> json.to_string, ) } -fn run_summary_json(run: types.RunRecord) -> json.Json { +pub fn provider_models_json( + repo_root: String, + provider_name: String, +) -> Result(String, String) { + use provider <- result.try( + parse_provider(provider_name) + |> result.map_error(fn(message) { + command_error_json(repo_root, "provider-models", message, None) + }), + ) + case provider_models.list_models(provider, repo_root) { + Ok(models) -> + Ok( + json.object([ + #("provider", json.string(types.provider_to_string(provider))), + #("models", json.array(models, provider_model_json)), + ]) + |> json.to_string, + ) + Error(message) -> + Error(command_error_json(repo_root, "provider-models", message, None)) + } +} + +pub fn command_json( + repo_root: String, + command: String, + body: String, +) -> Result(String, String) { + case command { + "init" -> init_command_json(repo_root, body) + "plan" -> plan_command_json(repo_root, body, False) + "plan-from-reviews" -> plan_command_json(repo_root, body, True) + "resolve" -> resolve_command_json(repo_root, body) + "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, + ), + ) + } +} + +pub fn runs_json(repo_root: String) -> Result(String, String) { + Ok( + list_runs_or_empty(repo_root) + |> list.map(run_summary_json) + |> json.array(identity_json) + |> json.to_string, + ) +} + +pub fn run_json(repo_root: String, run_id: String) -> Result(String, String) { + case load_run_details(repo_root, types.RunId(run_id)) { + Ok(#(run, events, configuration)) -> { + let repo_state_view = repo_state_view(run, configuration) + let rendered_report = report.render_live(run, events, repo_state_view) + Ok( + json.object([ + #("run", run_payload_json(run, events, configuration)), + #("events", json.array(events, event_json)), + #("report", json.string(rendered_report)), + ]) + |> json.to_string, + ) + } + Error(message) -> Error(message) + } +} + +fn bootstrap_payload( + repo_root: String, + requested_run_id: Option(String), +) -> json.Json { + let configuration = repo_configuration(repo_root) + 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) + json.object([ - #("run_id", json.string(run.run_id)), - #("status", json.string(types.run_status_to_string(run.status))), - #("planning_agent", agent_json(run.planning_agent)), - #("execution_agent", agent_json(run.execution_agent)), - #("created_at", json.string(run.created_at)), - #("updated_at", json.string(run.updated_at)), - #("brief_path", json.string(run.brief_path)), + #("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))), + #( + "config_error", + json.nullable(from: repo_config_error(configuration), of: json.string), + ), + #("commands", command_catalog_json(configuration.initialized)), + #( + "urls", + json.object([ + #("bootstrap", json.string("/api/bootstrap")), + #("events", json.string("/api/events")), + #("audit", json.string("/api/audit?run_id={run_id}")), + #( + "provider_models", + json.string("/api/provider-models?provider={provider}"), + ), + #("artifact", json.string("/api/artifacts?path={path}")), + ]), + ), + #( + "runs", + json.object([ + #("active_run_id", json.nullable(from: active_run_id, of: json.string)), + #("latest_run_id", json.nullable(from: latest_run_id, of: json.string)), + #( + "selected_run_id", + json.nullable(from: selected_run_id, of: json.string), + ), + #( + "active_run", + json.nullable( + from: find_run_by_id(runs, active_run_id), + of: run_summary_json, + ), + ), + #( + "latest_run", + json.nullable( + from: find_run_by_id(runs, latest_run_id), + of: run_summary_json, + ), + ), + #("items", json.array(runs, run_summary_json)), + ]), + ), + #( + "selected_run", + json.nullable( + from: selected_run_payload(repo_root, selected_run_id), + of: identity_json, + ), + ), ]) } -fn run_detail_json( +fn selected_run_payload( + repo_root: String, + requested_run_id: Option(String), +) -> Option(json.Json) { + case selected_run_selector(repo_root, requested_run_id) { + Some(selector) -> + case load_run_details(repo_root, selector) { + Ok(#(run, events, configuration)) -> + Some(run_payload_json(run, events, configuration)) + Error(_) -> None + } + None -> None + } +} + +fn run_payload_json( run: types.RunRecord, - review_projection: Option(review_run_projection.ReviewRunProjection), - confidence_assessment: types.ConfidenceAssessment, + events: List(types.RunEvent), + configuration: RepoConfiguration, ) -> json.Json { + let repo_state_view = repo_state_view(run, configuration) + let review_projection = + review_run_projection.build(run, events, repo_state_view) + let confidence_assessment = confidence.assess(run, events, repo_state_view) + let rendered_report = report.render_live(run, events, repo_state_view) + let pending_decisions = + decision_domain.pending_decision_prompts(run.decisions, run.tasks) + json.object([ #("run_id", json.string(run.run_id)), #("repo_root", json.string(run.repo_root)), @@ -266,8 +376,37 @@ fn run_detail_json( #("brief_path", json.string(run.brief_path)), #("report_path", json.string(run.report_path)), #("provenance_path", json.string(provenance.artifact_path(run))), - #("planning_agent", agent_json(run.planning_agent)), - #("execution_agent", agent_json(run.execution_agent)), + #("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), + ), + #("environment_name", json.string(run.environment_name)), + #("max_workers", json.int(run.max_workers)), + #("planning_dirty", json.bool(run.planning_dirty)), + #("status", json.string(types.run_status_to_string(run.status))), + #("created_at", json.string(run.created_at)), + #("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)) }, + ), + ), + #( + "notes_source", + json.nullable( + from: run.notes_source, + of: fn(value) { json.string(types.notes_source_label(value)) }, + ), + ), #( "confidence_posture", json.string(types.confidence_posture_to_string( @@ -278,87 +417,632 @@ fn run_detail_json( "confidence_reasons", json.array(confidence_assessment.reasons, json.string), ), - #("max_workers", json.int(run.max_workers)), - #("status", json.string(types.run_status_to_string(run.status))), - #("created_at", json.string(run.created_at)), - #("updated_at", json.string(run.updated_at)), + #( + "decision_requests", + json.array(pending_decisions, pending_decision_json), + ), + #("recorded_decisions", json.array(run.decisions, recorded_decision_json)), #( "repo_state", - json.nullable( - from: projection_repo_state(review_projection), - of: repo_state_json, - ), + json.nullable(from: review_projection, of: review_projection_repo_state_json), + ), + #( + "review_lineage", + json.nullable(from: review_projection, of: review_projection_json), + ), + #("task_dag", task_dag_json(run.tasks)), + #("tasks", json.array(run.tasks, task_json(_, run, events))), + #("timeline", json.array(events, event_json)), + #( + "report", + json.object([ + #("path", json.string(run.report_path)), + #("route", json.string(artifact_route(run.report_path))), + #("content", json.string(rendered_report)), + ]), + ), + #( + "provenance", + json.object([ + #("path", json.string(provenance.artifact_path(run))), + #("route", json.string(artifact_route(provenance.artifact_path(run)))), + ]), + ), + #( + "planning_artifacts", + json.array(planning_artifact_paths(run, events), artifact_json), + ), + #( + "planner_prompt", + json.nullable(from: planner_prompt_path(run.run_path), of: artifact_json), + ), + #( + "planner_log", + json.nullable(from: planner_log_path(run.run_path), of: artifact_json), + ), + #("handoff_states", json.array(run.handoff_states, handoff_state_json)), + ]) +} + +fn init_command_json(repo_root: String, body: String) -> Result(String, String) { + let configuration = repo_configuration(repo_root) + use request <- result.try( + decode_or_default(body, init_request_decoder(), default_init_request()) + |> result.map_error(fn(message) { + command_error_json(repo_root, "init", decode_message(message), None) + }), + ) + use agent_overrides <- result.try( + agent_overrides_from_strings( + request.profile, + request.provider, + request.model, + request.reasoning, + ) + |> result.map_error(fn(message) { + command_error_json(repo_root, "init", message, None) + }), + ) + use base_config <- result.try( + configuration.config_result + |> result.map_error(fn(message) { + command_error_json(repo_root, "init", message, None) + }), + ) + + case + init_usecase.execute( + repo_root, + base_config, + agent_overrides, + request.generate_setup, + True, + dash_select_provider, + dash_select_model, + dash_choose_setup_request, + ) + { + Ok(view) -> + Ok(command_success_json( + repo_root, + "init", + "Initialized Night Shift for this repository.", + None, + Some(view.next_action), + )) + Error(message) -> + Error(command_error_json(repo_root, "init", message, None)) + } +} + +fn plan_command_json( + repo_root: String, + body: String, + from_reviews: Bool, +) -> Result(String, String) { + let command_name = case from_reviews { + True -> "plan-from-reviews" + False -> "plan" + } + use request <- result.try( + decode_or_default(body, plan_request_decoder(), default_plan_request()) + |> result.map_error(fn(message) { + command_error_json(repo_root, command_name, decode_message(message), None) + }), + ) + use configuration <- result.try( + require_initialized_config(repo_root, command_name) + ) + use agent_overrides <- result.try( + agent_overrides_from_strings( + request.profile, + request.provider, + request.model, + request.reasoning, + ) + |> result.map_error(fn(message) { + command_error_json(repo_root, command_name, message, None) + }), + ) + use planning_agent <- result.try( + agent_config.resolve_plan_agent(extract_config(configuration), agent_overrides) + |> result.map_error(fn(message) { + command_error_json(repo_root, command_name, message, None) + }), + ) + + case + plan_usecase.execute( + repo_root, + optional_string(request.notes), + from_reviews, + optional_string(request.doc_path), + planning_agent, + extract_config(configuration), + ) + { + Ok(view) -> + Ok(command_success_json( + repo_root, + command_name, + "Planned run " <> view.run.run_id <> ".", + Some(view.run.run_id), + Some(view.next_action), + )) + Error(message) -> + Error(command_error_json(repo_root, command_name, message, None)) + } +} + +fn resolve_command_json( + repo_root: String, + body: String, +) -> Result(String, String) { + use request <- result.try( + decode_or_default( + body, + resolve_request_decoder(), + default_resolve_request(), + ) + |> result.map_error(fn(message) { + command_error_json(repo_root, "resolve", decode_message(message), None) + }), + ) + let selector = run_selector(request.run_id) + + case + resolve_usecase.execute( + repo_root, + selector, + fn(run, tasks) { collect_dash_decisions(run, tasks, request.answers) }, + ) + { + Ok(view) -> + Ok(command_success_json( + repo_root, + "resolve", + resolve_message(view), + Some(view.run.run_id), + Some(view.next_action), + )) + Error(message) -> + Error(command_error_json( + repo_root, + "resolve", + message, + run_id_option(request.run_id), + )) + } +} + +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) { + command_error_json(repo_root, "start", decode_message(message), None) + }), + ) + use configuration <- result.try(require_initialized_config(repo_root, "start")) + + case start_usecase.execute(repo_root, run_selector(request.run_id), extract_config(configuration)) { + Ok(view) -> + Ok(command_success_json( + repo_root, + "start", + start_message(view), + Some(view.run.run_id), + Some(view.next_action), + )) + Error(message) -> + Error(command_error_json( + repo_root, + "start", + message, + run_id_option(request.run_id), + )) + } +} + +fn resume_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) { + command_error_json(repo_root, "resume", decode_message(message), None) + }), + ) + use configuration <- result.try( + require_initialized_config(repo_root, "resume") + ) + + case resume_usecase.execute( + repo_root, + run_selector(request.run_id), + extract_config(configuration), + ) { + Ok(view) -> + Ok(command_success_json( + repo_root, + "resume", + resume_message(view), + Some(view.run.run_id), + Some(view.next_action), + )) + Error(message) -> + Error(command_error_json( + repo_root, + "resume", + message, + run_id_option(request.run_id), + )) + } +} + +fn command_success_json( + repo_root: String, + command: String, + message: String, + run_id: Option(String), + next_action: Option(String), +) -> String { + json.object([ + #("ok", json.bool(True)), + #("command", json.string(command)), + #("message", json.string(message)), + #("run_id", json.nullable(from: run_id, of: json.string)), + #("next_action", json.nullable(from: next_action, of: json.string)), + #("bootstrap", bootstrap_payload(repo_root, run_id)), + ]) + |> json.to_string +} + +fn command_error_json( + repo_root: String, + command: String, + message: String, + run_id: Option(String), +) -> String { + json.object([ + #("ok", json.bool(False)), + #("command", json.string(command)), + #("message", json.string(message)), + #("run_id", json.nullable(from: run_id, of: json.string)), + #("bootstrap", bootstrap_payload(repo_root, run_id)), + ]) + |> json.to_string +} + +fn command_catalog_json(initialized: Bool) -> json.Json { + json.object([ + #("init", command_entry_json("/api/commands/init", True)), + #("plan", command_entry_json("/api/commands/plan", initialized)), + #( + "plan_from_reviews", + command_entry_json("/api/commands/plan-from-reviews", initialized), ), - #("tasks", json.array(run.tasks, task_json)), + #("resolve", command_entry_json("/api/commands/resolve", initialized)), + #("start", command_entry_json("/api/commands/start", initialized)), + #("resume", command_entry_json("/api/commands/resume", initialized)), + ]) +} + +fn command_entry_json(path: String, available: Bool) -> json.Json { + json.object([ + #("method", json.string("POST")), + #("path", json.string(path)), + #("available", json.bool(available)), + ]) +} + +fn run_summary_json(run: types.RunRecord) -> json.Json { + json.object([ + #("run_id", json.string(run.run_id)), + #("status", json.string(types.run_status_to_string(run.status))), + #("planning_dirty", json.bool(run.planning_dirty)), + #("created_at", json.string(run.created_at)), + #("updated_at", json.string(run.updated_at)), + #("brief_path", json.string(run.brief_path)), + #("audit_route", json.string("/api/audit?run_id=" <> run.run_id)), ]) } -fn task_json(task: types.Task) -> json.Json { +fn task_json( + task: types.Task, + run: types.RunRecord, + events: List(types.RunEvent), +) -> json.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) + json.object([ #("id", json.string(task.id)), #("title", json.string(task.title)), #("description", json.string(task.description)), + #("dependencies", json.array(task.dependencies, json.string)), + #("acceptance", json.array(task.acceptance, json.string)), + #("demo_plan", json.array(task.demo_plan, json.string)), + #( + "decision_requests", + json.array(task.decision_requests, decision_request_json), + ), + #( + "unresolved_decision_requests", + json.array( + types.unresolved_decision_requests(run.decisions, task), + decision_request_json, + ), + ), + #( + "superseded_pr_numbers", + json.array(task.superseded_pr_numbers, json.int), + ), + #("task_kind", json.string(types.task_kind_to_string(task.kind))), + #( + "execution_mode", + json.string(types.execution_mode_to_string(task.execution_mode)), + ), #("state", json.string(types.task_state_to_string(task.state))), + #("worktree_path", json.string(task.worktree_path)), #("branch_name", json.string(task.branch_name)), #("pr_number", json.string(task.pr_number)), #("summary", json.string(task.summary)), + #( + "runtime_context", + json.nullable(from: task.runtime_context, of: runtime_context_json), + ), + #( + "delivered_pr", + json.nullable(from: delivered_link, of: delivered_pr_json), + ), + #( + "artifacts", + json.object([ + #( + "prompts", + json.array(task_prompt_paths(run.run_path, task.id), artifact_json), + ), + #( + "logs", + json.array(task_log_paths(run.run_path, task.id), artifact_json), + ), + #( + "raw_payloads", + json.array(raw_payload_paths(run.run_path, task.id), artifact_json), + ), + #( + "sanitized_payloads", + json.array( + sanitized_payload_paths(run.run_path, task.id), + artifact_json, + ), + ), + #( + "runtime_manifest", + json.nullable( + from: runtime_manifest_path(task.runtime_context), + of: artifact_json, + ), + ), + #( + "runtime_handoff", + json.nullable( + from: runtime_handoff_path(task.runtime_context), + of: artifact_json, + ), + ), + #( + "runtime_env", + json.nullable( + from: runtime_env_path(task.runtime_context), + of: artifact_json, + ), + ), + ]), + ), + #("timeline", json.array(relevant_events, event_json)), ]) } -fn load_repo_state_view( - run: types.RunRecord, -) -> Option(repo_state_runtime.RepoStateView) { - case config.load(project.config_path(run.repo_root)) { - Ok(loaded_config) -> - repo_state_runtime.inspect(run, loaded_config.branch_prefix).view - Error(_) -> None - } +fn task_dag_json(tasks: List(types.Task)) -> json.Json { + json.object([ + #("task_count", json.int(list.length(tasks))), + #( + "ready_task_ids", + json.array( + tasks + |> list.filter(fn(task) { task.state == types.Ready }) + |> list.map(fn(task) { task.id }), + json.string, + ), + ), + #( + "running_task_ids", + json.array( + tasks + |> list.filter(fn(task) { task.state == types.Running }) + |> list.map(fn(task) { task.id }), + json.string, + ), + ), + #( + "blocked_task_ids", + json.array( + tasks + |> list.filter(fn(task) { + task.state == types.Blocked || task.state == types.ManualAttention + }) + |> list.map(fn(task) { task.id }), + json.string, + ), + ), + #( + "completed_task_ids", + json.array( + tasks + |> list.filter(fn(task) { task.state == types.Completed }) + |> list.map(fn(task) { task.id }), + json.string, + ), + ), + ]) +} + +fn review_projection_json( + projection: review_run_projection.ReviewRunProjection, +) -> json.Json { + json.object([ + #("repo_state", review_projection_repo_state_json(projection)), + #( + "lineage_entries", + json.array(projection.lineage_entries, lineage_entry_json), + ), + #( + "supersession_outcomes", + json.array(projection.supersession_outcomes, json.string), + ), + #( + "supersession_warnings", + json.array(projection.supersession_warnings, json.string), + ), + ]) } -fn repo_state_json(summary: review_run_projection.RepoStateSummary) -> json.Json { +fn review_projection_repo_state_json( + projection: review_run_projection.ReviewRunProjection, +) -> json.Json { + let summary = projection.repo_state + json.object([ + #( + "captured_open_pr_count", + json.int(summary.captured_open_pr_count), + ), + #( + "captured_actionable_pr_count", + json.int(summary.captured_actionable_pr_count), + ), #("snapshot_captured_at", json.string(summary.snapshot_captured_at)), #( - "open_pr_count", - json.int(review_run_projection.current_or_captured_open_count(summary)), + "current_open_pr_count", + json.nullable(from: summary.current_open_pr_count, of: json.int), ), #( - "actionable_pr_count", - json.int(review_run_projection.current_or_captured_actionable_count( - summary, - )), + "current_actionable_pr_count", + json.nullable(from: summary.current_actionable_pr_count, of: json.int), + ), + #("drift", json.nullable(from: summary.drift, of: json.string)), + #( + "drift_details", + json.nullable(from: summary.drift_details, of: json.string), + ), + #( + "actionable_pull_requests", + json.array(summary.actionable_pull_requests, repo_pull_request_json), + ), + #( + "impacted_pull_requests", + json.array(summary.impacted_pull_requests, repo_pull_request_json), + ), + ]) +} + +fn repo_pull_request_json(pr: repo_state.RepoPullRequestSnapshot) -> json.Json { + json.object([ + #("number", json.int(pr.number)), + #("title", json.string(pr.title)), + #("url", json.string(pr.url)), + #("head_ref_name", json.string(pr.head_ref_name)), + #("base_ref_name", json.string(pr.base_ref_name)), + #("review_decision", json.string(pr.review_decision)), + #("review_comments", json.array(pr.review_comments, json.string)), + #("actionable", json.bool(pr.actionable)), + #("impacted", json.bool(pr.impacted)), + ]) +} + +fn lineage_entry_json( + entry: review_run_projection.ReplacementLineageEntry, +) -> json.Json { + json.object([ + #("task_id", json.string(entry.task_id)), + #( + "superseded_pr_numbers", + json.array(entry.superseded_pr_numbers, json.int), + ), + #( + "replacement_pr_number", + json.nullable(from: entry.replacement_pr_number, of: json.string), ), + ]) +} + +fn runtime_context_json(context: types.RuntimeContext) -> json.Json { + json.object([ + #("worktree_id", json.string(context.worktree_id)), + #("compose_project", json.string(context.compose_project)), + #("port_base", json.int(context.port_base)), #( - "drift", - json.string(case summary.drift { - Some(drift) -> drift - None -> "unknown" + "named_ports", + json.array(context.named_ports, fn(port) { + json.object([ + #("name", json.string(port.name)), + #("value", json.int(port.value)), + ]) }), ), + #("runtime_dir", json.string(context.runtime_dir)), + #("env_file_path", json.string(context.env_file_path)), + #("manifest_path", json.string(context.manifest_path)), + #("handoff_path", json.string(context.handoff_path)), ]) } -fn projection_repo_state( - review_projection: Option(review_run_projection.ReviewRunProjection), -) -> Option(review_run_projection.RepoStateSummary) { - case review_projection { - Some(projection) -> Some(projection.repo_state) - None -> None - } +fn delivered_pr_json(link: DeliveredPrLink) -> json.Json { + json.object([ + #("number", json.string(link.number)), + #("url", json.nullable(from: link.url, of: json.string)), + #( + "handoff_state", + json.nullable(from: link.handoff_state, of: handoff_state_json), + ), + ]) } -fn agent_json(agent: types.ResolvedAgentConfig) -> json.Json { +fn handoff_state_json(state: types.TaskHandoffState) -> json.Json { json.object([ - #("profile_name", json.string(agent.profile_name)), - #("provider", json.string(types.provider_to_string(agent.provider))), - #("model", case agent.model { - Some(model) -> json.string(model) - None -> json.null() - }), - #("reasoning", case agent.reasoning { - Some(reasoning) -> json.string(types.reasoning_to_string(reasoning)) - None -> json.null() - }), + #("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_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)), + #("managed_comment_present", json.bool(state.managed_comment_present)), + ]) +} + +fn provider_model_json(model: provider_models.ProviderModel) -> json.Json { + json.object([ + #("id", json.string(model.id)), + #("label", json.string(model.label)), + #("is_default", json.bool(model.is_default)), ]) } @@ -367,13 +1051,714 @@ fn event_json(event: types.RunEvent) -> json.Json { #("kind", json.string(event.kind)), #("at", json.string(event.at)), #("message", json.string(event.message)), - #("task_id", case event.task_id { - Some(task_id) -> json.string(task_id) - None -> json.null() - }), + #("task_id", json.nullable(from: event.task_id, of: json.string)), + ]) +} + +fn artifact_json(path: String) -> json.Json { + json.object([ + #("path", json.string(path)), + #("route", json.string(artifact_route(path))), + ]) +} + +fn pending_decision_json( + prompt: #(types.Task, types.DecisionRequest), +) -> json.Json { + let #(task, request) = prompt + json.object([ + #("task_id", json.string(task.id)), + #("task_title", json.string(task.title)), + #("request", decision_request_json(request)), + ]) +} + +fn decision_request_json(request: types.DecisionRequest) -> json.Json { + json.object([ + #("key", json.string(request.key)), + #("question", json.string(request.question)), + #("rationale", json.string(request.rationale)), + #( + "options", + json.array(request.options, fn(option) { + json.object([ + #("label", json.string(option.label)), + #("description", json.string(option.description)), + ]) + }), + ), + #( + "recommended_option", + json.nullable(from: request.recommended_option, of: json.string), + ), + #("allow_freeform", json.bool(request.allow_freeform)), + ]) +} + +fn recorded_decision_json(decision: types.RecordedDecision) -> json.Json { + json.object([ + #("key", json.string(decision.key)), + #("question", json.string(decision.question)), + #("answer", json.string(decision.answer)), + #("answered_at", json.string(decision.answered_at)), + ]) +} + +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()) + }, + ) +} + +fn require_initialized_config( + repo_root: String, + command: String, +) -> Result(RepoConfiguration, String) { + 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, + ), + ) + True, Ok(_) -> Ok(configuration) + True, Error(message) -> + Error(command_error_json(repo_root, command, message, None)) + } +} + +fn extract_config(configuration: RepoConfiguration) -> types.Config { + case configuration.config_result { + Ok(config) -> config + Error(_) -> types.default_config() + } +} + +fn repo_config_error(configuration: RepoConfiguration) -> Option(String) { + case configuration.config_result { + Ok(_) -> None + Error(message) -> Some(message) + } +} + +fn list_runs_or_empty(repo_root: String) -> List(types.RunRecord) { + case journal.list_runs(repo_root) { + Ok(runs) -> runs + Error(_) -> [] + } +} + +fn load_run_details( + repo_root: String, + selector: types.RunSelector, +) -> Result(#(types.RunRecord, List(types.RunEvent), RepoConfiguration), String) { + let configuration = repo_configuration(repo_root) + journal.load(repo_root, selector) + |> result.map(fn(value) { + let #(run, events) = value + #(run, events, configuration) + }) +} + +fn active_run_id_or_none(repo_root: String) -> Option(String) { + case journal.active_run_id(repo_root) { + Ok(run_id) -> Some(run_id) + Error(_) -> None + } +} + +fn latest_run_id(runs: List(types.RunRecord)) -> Option(String) { + case runs { + [run, ..] -> Some(run.run_id) + [] -> None + } +} + +fn choose_selected_run_id( + requested: Option(String), + active: Option(String), + runs: List(types.RunRecord), +) -> Option(String) { + case requested { + Some(run_id) -> Some(run_id) + None -> + case active { + Some(run_id) -> Some(run_id) + None -> latest_run_id(runs) + } + } +} + +fn selected_run_selector( + repo_root: String, + requested_run_id: Option(String), +) -> Option(types.RunSelector) { + let runs = list_runs_or_empty(repo_root) + let active = active_run_id_or_none(repo_root) + case choose_selected_run_id(requested_run_id, active, runs) { + Some(run_id) -> Some(types.RunId(run_id)) + None -> None + } +} + +fn find_run_by_id( + runs: List(types.RunRecord), + run_id: Option(String), +) -> Option(types.RunRecord) { + case run_id { + Some(target) -> + case list.find(runs, fn(run) { run.run_id == target }) { + Ok(run) -> Some(run) + Error(_) -> None + } + None -> None + } +} + +fn repo_state_view( + run: types.RunRecord, + configuration: RepoConfiguration, +) -> Option(repo_state_runtime.RepoStateView) { + case configuration.config_result { + Ok(config) -> repo_state_runtime.inspect(run, config.branch_prefix).view + Error(_) -> None + } +} + +fn run_selector(run_id: String) -> types.RunSelector { + case optional_string(run_id) { + Some(value) -> types.RunId(value) + None -> types.LatestRun + } +} + +fn run_id_option(run_id: String) -> Option(String) { + optional_string(run_id) +} + +fn optional_string(value: String) -> Option(String) { + case string.trim(value) { + "" -> None + trimmed -> Some(trimmed) + } +} + +fn artifact_route(path: String) -> String { + "/api/artifacts?path=" <> path +} + +fn decode_or_default( + body: String, + decoder: decode.Decoder(a), + default: a, +) -> Result(a, json.DecodeError) { + case string.trim(body) { + "" -> Ok(default) + _ -> json.parse(body, decoder) + } +} + +fn init_request_decoder() -> decode.Decoder(RawInitRequest) { + use profile <- decode.optional_field("profile", "", decode.string) + 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) + decode.success(RawInitRequest( + profile: profile, + provider: provider, + model: model, + reasoning: reasoning, + generate_setup: generate_setup, + )) +} + +fn default_init_request() -> RawInitRequest { + RawInitRequest( + profile: "", + provider: "", + model: "", + reasoning: "", + generate_setup: False, + ) +} + +fn plan_request_decoder() -> decode.Decoder(RawPlanRequest) { + use run_id <- decode.optional_field("run_id", "", decode.string) + use notes <- decode.optional_field("notes", "", decode.string) + use doc_path <- decode.optional_field("doc_path", "", decode.string) + use profile <- decode.optional_field("profile", "", decode.string) + use provider <- decode.optional_field("provider", "", decode.string) + use model <- decode.optional_field("model", "", decode.string) + use reasoning <- decode.optional_field("reasoning", "", decode.string) + decode.success(RawPlanRequest( + run_id: run_id, + notes: notes, + doc_path: doc_path, + profile: profile, + provider: provider, + model: model, + reasoning: reasoning, + )) +} + +fn default_plan_request() -> RawPlanRequest { + RawPlanRequest( + run_id: "", + notes: "", + doc_path: "", + profile: "", + provider: "", + model: "", + reasoning: "", + ) +} + +fn run_request_decoder() -> decode.Decoder(RawRunRequest) { + use run_id <- decode.optional_field("run_id", "", decode.string) + decode.success(RawRunRequest(run_id: run_id)) +} + +fn resolve_request_decoder() -> decode.Decoder(RawResolveRequest) { + use run_id <- decode.optional_field("run_id", "", decode.string) + use answers <- decode.optional_field( + "answers", + [], + decode.list(decision_answer_decoder()), + ) + decode.success(RawResolveRequest(run_id: run_id, answers: answers)) +} + +fn default_resolve_request() -> RawResolveRequest { + RawResolveRequest(run_id: "", answers: []) +} + +fn decision_answer_decoder() -> decode.Decoder(RawDecisionAnswer) { + use key <- decode.field("key", decode.string) + use answer <- decode.field("answer", decode.string) + decode.success(RawDecisionAnswer(key: key, answer: answer)) +} + +fn decode_message(error: json.DecodeError) -> String { + case error { + json.UnexpectedEndOfInput -> "Invalid JSON payload: unexpected end of input." + json.UnexpectedByte(byte) -> + "Invalid JSON payload: unexpected byte `" <> byte <> "`." + json.UnexpectedSequence(sequence) -> + "Invalid JSON payload: unexpected sequence `" <> sequence <> "`." + json.UnableToDecode(_) -> + "Invalid JSON payload: structure did not match the expected command shape." + } +} + +fn agent_overrides_from_strings( + profile: String, + provider: String, + model: String, + reasoning: String, +) -> Result(types.AgentOverrides, String) { + use parsed_provider <- result.try(optional_result( + optional_string(provider), + parse_provider, + )) + use parsed_reasoning <- result.try(optional_result( + optional_string(reasoning), + parse_reasoning, + )) + Ok(types.AgentOverrides( + profile: optional_string(profile), + provider: parsed_provider, + model: optional_string(model), + reasoning: parsed_reasoning, + )) +} + +fn optional_result( + value: Option(a), + parser: fn(a) -> Result(b, String), +) -> Result(Option(b), String) { + case value { + Some(inner) -> parser(inner) |> result.map(Some) + None -> Ok(None) + } +} + +fn parse_provider(value: String) -> Result(types.Provider, String) { + types.provider_from_string(string.trim(value)) +} + +fn parse_reasoning(value: String) -> Result(types.ReasoningLevel, String) { + types.reasoning_from_string(string.trim(value)) +} + +fn dash_select_provider( + _config: types.Config, + agent_overrides: types.AgentOverrides, +) -> Result(types.Provider, String) { + case agent_overrides.provider { + Some(provider) -> Ok(provider) + None -> + Error( + "Dash init requires `provider` when this repository has not been initialized yet.", + ) + } +} + +fn dash_select_model( + _repo_root: String, + _config: types.Config, + _provider_name: types.Provider, + agent_overrides: types.AgentOverrides, +) -> Result(String, String) { + case agent_overrides.model { + Some(model) -> Ok(model) + None -> + Error( + "Dash init requires `model` when this repository has not been initialized yet.", + ) + } +} + +fn dash_choose_setup_request( + generate_setup: Bool, + _assume_yes: Bool, + setup_exists: Bool, +) -> Result(Bool, String) { + case setup_exists { + True -> Ok(False) + False -> Ok(generate_setup) + } +} + +fn collect_dash_decisions( + run: types.RunRecord, + tasks: List(types.Task), + answers: List(RawDecisionAnswer), +) -> Result(#(List(types.RecordedDecision), List(types.RunEvent)), String) { + let prompts = decision_domain.pending_decision_prompts(run.decisions, tasks) + case prompts { + [] -> + Error("No unresolved manual-attention decisions were found for this run.") + _ -> + collect_dash_decisions_loop(prompts, answers, [], []) + } +} + +fn collect_dash_decisions_loop( + prompts: List(#(types.Task, types.DecisionRequest)), + answers: List(RawDecisionAnswer), + recorded: List(types.RecordedDecision), + warnings: List(types.RunEvent), +) -> Result(#(List(types.RecordedDecision), List(types.RunEvent)), String) { + case prompts { + [] -> Ok(#(list.reverse(recorded), list.reverse(warnings))) + [#(task, request), ..rest] -> { + use #(answer, warning) <- result.try(resolve_dash_answer( + task, + request, + answers, + )) + let next_recorded = + types.RecordedDecision( + key: request.key, + question: request.question, + answer: answer, + answered_at: system.timestamp(), + ) + let next_warnings = case warning { + Some(event) -> [event, ..warnings] + None -> warnings + } + collect_dash_decisions_loop( + rest, + answers, + [next_recorded, ..recorded], + next_warnings, + ) + } + } +} + +fn resolve_dash_answer( + task: types.Task, + request: types.DecisionRequest, + answers: List(RawDecisionAnswer), +) -> Result(#(String, Option(types.RunEvent)), String) { + use answer <- result.try(find_decision_answer(answers, request.key)) + case request.options { + [] -> + 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)) + False -> + case request.allow_freeform { + True -> Ok(#(answer, None)) + False -> + Error( + "Decision `" + <> request.key + <> "` requires one of the declared option labels.", + ) + } + } + } +} + +fn find_decision_answer( + answers: List(RawDecisionAnswer), + key: String, +) -> Result(String, String) { + case list.find(answers, fn(answer) { answer.key == key }) { + Ok(answer) -> Ok(answer.answer) + Error(_) -> Error("Missing answer for decision `" <> key <> "`.") + } +} + +fn decision_contract_warning_event( + task: types.Task, + request: types.DecisionRequest, +) -> types.RunEvent { + types.RunEvent( + kind: "decision_contract_warning", + at: system.timestamp(), + message: "Coerced `" + <> request.key + <> "` into a freeform prompt because the planner returned no options and disallowed freeform input.", + task_id: Some(task.id), + ) +} + +fn resolve_message(view: workflow.ResolveResult) -> String { + case view.summary { + Some(summary) -> summary + None -> + "Resolved run " + <> view.run.run_id + <> "." + } +} + +fn start_message(view: workflow.StartResult) -> String { + "Start completed for run " + <> view.run.run_id + <> " with status " + <> types.run_status_to_string(view.run.status) + <> "." +} + +fn resume_message(view: workflow.ResumeResult) -> String { + "Resume completed for run " + <> view.run.run_id + <> " with status " + <> types.run_status_to_string(view.run.status) + <> "." +} + +fn delivered_pr_link( + task: types.Task, + handoff_states: List(types.TaskHandoffState), + events: List(types.RunEvent), +) -> Option(DeliveredPrLink) { + let handoff_state = types.task_handoff_state(handoff_states, task.id) + let pr_number = case string.trim(task.pr_number) { + "" -> + case handoff_state { + Some(state) -> state.delivered_pr_number + None -> "" + } + value -> value + } + + case pr_number { + "" -> None + _ -> + Some(DeliveredPrLink( + number: pr_number, + url: latest_pr_url(events), + handoff_state: handoff_state, + )) + } +} + +fn latest_pr_url(events: List(types.RunEvent)) -> Option(String) { + case + events + |> list.filter(fn(event) { event.kind == "pr_opened" }) + |> list.reverse + { + [event, ..] -> Some(event.message) + [] -> None + } +} + +fn planning_artifact_paths( + run: types.RunRecord, + events: List(types.RunEvent), +) -> List(String) { + let event_paths = + events + |> list.filter(fn(event) { event.kind == "planning_artifacts_recorded" }) + |> list.filter_map(fn(event) { + case string.split_once(event.message, "Planning artifacts: ") { + Ok(#(_, path)) -> + case string.trim(path) { + "" -> Error(Nil) + trimmed -> Ok(trimmed) + } + Error(_) -> Error(Nil) + } + }) + + let candidate_paths = case run.notes_source { + Some(types.InlineNotes(path)) -> [path, ..event_paths] + _ -> event_paths + } + + candidate_paths |> list.filter(file_or_directory_exists) +} + +fn planner_prompt_path(run_path: String) -> Option(String) { + existing_file(filepath.join(run_path, "planner.prompt.md")) +} + +fn planner_log_path(run_path: String) -> Option(String) { + existing_file(filepath.join(run_path, "logs/planner.log")) +} + +fn task_prompt_paths(run_path: String, task_id: String) -> List(String) { + [ + filepath.join(run_path, "logs/" <> task_id <> ".prompt.md"), + filepath.join(run_path, "logs/" <> task_id <> ".repair.prompt.md"), + filepath.join(run_path, "logs/" <> task_id <> ".payload-repair.prompt.md"), + ] + |> existing_files +} + +fn task_log_paths(run_path: String, task_id: String) -> List(String) { + [ + filepath.join(run_path, "logs/" <> task_id <> ".log"), + filepath.join(run_path, "logs/" <> task_id <> ".repair.log"), + filepath.join(run_path, "logs/" <> task_id <> ".payload-repair.log"), + filepath.join(run_path, "logs/" <> task_id <> ".verify.log"), + filepath.join(run_path, "logs/" <> task_id <> ".git.log"), + filepath.join(run_path, "logs/" <> task_id <> ".env.log"), + ] + |> existing_files +} + +fn raw_payload_paths(run_path: String, task_id: String) -> List(String) { + [ + filepath.join(run_path, "logs/" <> task_id <> ".result.raw.jsonish"), + filepath.join( + run_path, + "logs/" <> task_id <> ".payload-repair.result.raw.jsonish", + ), + ] + |> existing_files +} + +fn sanitized_payload_paths(run_path: String, task_id: String) -> List(String) { + [ + filepath.join(run_path, "logs/" <> task_id <> ".result.sanitized.json"), + filepath.join( + run_path, + "logs/" <> task_id <> ".payload-repair.result.sanitized.json", + ), + ] + |> existing_files +} + +fn runtime_manifest_path( + context: Option(types.RuntimeContext), +) -> Option(String) { + case context { + Some(value) -> existing_file(value.manifest_path) + None -> None + } +} + +fn runtime_handoff_path( + context: Option(types.RuntimeContext), +) -> Option(String) { + case context { + Some(value) -> existing_file(value.handoff_path) + None -> None + } +} + +fn runtime_env_path(context: Option(types.RuntimeContext)) -> Option(String) { + case context { + Some(value) -> existing_file(value.env_file_path) + None -> None + } +} + +fn existing_files(paths: List(String)) -> List(String) { + paths |> list.filter_map(fn(path) { + case existing_file(path) { + Some(value) -> Ok(value) + None -> Error(Nil) + } + }) +} + +fn existing_file(path: String) -> Option(String) { + case simplifile.read(path) { + Ok(_) -> Some(path) + Error(_) -> None + } +} + +fn file_exists(path: String) -> Bool { + case simplifile.read(path) { + Ok(_) -> True + Error(_) -> False + } +} + +fn file_or_directory_exists(path: String) -> Bool { + case simplifile.read(path) { + Ok(_) -> True + Error(_) -> + case simplifile.read_directory(at: path) { + Ok(_) -> True + Error(_) -> False + } + } +} + +fn agent_json(agent: types.ResolvedAgentConfig) -> json.Json { + json.object([ + #("profile_name", json.string(agent.profile_name)), + #("provider", json.string(types.provider_to_string(agent.provider))), + #("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)) }, + ), + ), ]) } -fn identity(value: json.Json) -> json.Json { +fn identity_json(value: json.Json) -> json.Json { value } diff --git a/src/night_shift/demo.gleam b/src/night_shift/demo.gleam index dd1fb79..27018c7 100644 --- a/src/night_shift/demo.gleam +++ b/src/night_shift/demo.gleam @@ -134,8 +134,8 @@ fn run_headless_demo( } fn run_ui_demo(repo_root: String, demo_root: String) -> Result(String, String) { - let log_path = filepath.join(demo_root, "ui-start.log") - let pid_path = filepath.join(demo_root, "ui-start.pid") + let log_path = filepath.join(demo_root, "dash.log") + let pid_path = filepath.join(demo_root, "dash.pid") use _plan_output <- result.try(run_cli_command( ["plan", "--notes", "Implement the demo task with a proof file."], @@ -144,7 +144,19 @@ fn run_ui_demo(repo_root: String, demo_root: String) -> Result(String, String) { "UI demo failed while running `plan`.", )) use _ <- result.try(start_ui_command(repo_root, demo_root, log_path, pid_path)) - use #(url, run_id) <- result.try(wait_for_ui_details(log_path, 40)) + use url <- result.try(wait_for_ui_details(log_path, 40)) + use bootstrap <- result.try(wait_for_bootstrap_payload(url, 40)) + use run_id <- result.try(extract_json_string( + bootstrap, + "\"selected_run_id\":\"", + "UI demo bootstrap did not expose the planned run id.", + )) + use _ <- result.try(post_dashboard_command( + url, + "start", + "{\"run_id\":\"" <> run_id <> "\"}", + "UI demo failed while posting the `start` command.", + )) use payload <- result.try(wait_for_completed_dashboard_payload( url, run_id, @@ -158,7 +170,7 @@ fn run_ui_demo(repo_root: String, demo_root: String) -> Result(String, String) { )) use _ <- result.try(assert_contains( payload, - "\"pr_number\":\"1\"", + "\"number\":\"1\"", "UI demo dashboard never showed the delivered PR.", )) @@ -181,7 +193,7 @@ fn run_ui_demo(repo_root: String, demo_root: String) -> Result(String, String) { } |> result.map(fn(_) { "Demo succeeded.\n" - <> "Validated UI flows: plan, start --ui, dashboard payload, status\n" + <> "Validated UI flows: dash, bootstrap, start, status\n" <> "Dashboard: " <> url <> "\n" @@ -300,7 +312,7 @@ fn start_ui_command( ) -> Result(Nil, String) { let command = "nohup " - <> build_cli_command(["start", "--ui"]) + <> build_cli_command(["dash"]) <> " > " <> shell.quote(log_path) <> " 2>&1 & echo $! > " @@ -326,19 +338,16 @@ fn stop_ui_command(demo_root: String, pid_path: String) -> Result(Nil, String) { fn wait_for_ui_details( log_path: String, attempts: Int, -) -> Result(#(String, String), String) { +) -> Result(String, String) { case attempts { value if value <= 0 -> Error("UI demo did not publish a dashboard URL in time.") _ -> case simplifile.read(log_path) { Ok(contents) -> - case - extract_prefixed_line(contents, "Dashboard: "), - extract_prefixed_line(contents, "Run: ") - { - Ok(url), Ok(run_id) -> Ok(#(url, run_id)) - _, _ -> { + case extract_prefixed_line(contents, "Dash: ") { + Ok(url) -> Ok(url) + Error(_) -> { system.sleep(150) wait_for_ui_details(log_path, attempts - 1) } @@ -351,6 +360,32 @@ fn wait_for_ui_details( } } +fn wait_for_bootstrap_payload( + url: String, + attempts: Int, +) -> Result(String, String) { + let endpoint = url <> "/api/bootstrap" + case attempts { + value if value <= 0 -> + Error("UI demo bootstrap never became readable.") + _ -> + case dashboard.http_get(endpoint) { + Ok(payload) -> + case string.contains(does: payload, contain: "\"selected_run_id\":\"") { + True -> Ok(payload) + False -> { + system.sleep(150) + wait_for_bootstrap_payload(url, attempts - 1) + } + } + Error(_) -> { + system.sleep(150) + wait_for_bootstrap_payload(url, attempts - 1) + } + } + } +} + fn wait_for_completed_status( repo_root: String, log_path: String, @@ -399,7 +434,7 @@ fn wait_for_completed_dashboard_payload( run_id: String, attempts: Int, ) -> Result(String, String) { - let endpoint = url <> "/api/runs/" <> run_id + let endpoint = url <> "/api/audit?run_id=" <> run_id case attempts { value if value <= 0 -> Error("UI demo dashboard never reached a completed state.") @@ -423,6 +458,18 @@ fn wait_for_completed_dashboard_payload( } } +fn post_dashboard_command( + url: String, + command: String, + body: String, + error_message: String, +) -> Result(String, String) { + case dashboard.http_post(url <> "/api/commands/" <> command, body) { + Ok(payload) -> Ok(payload) + Error(_) -> Error(error_message) + } +} + fn extract_prefixed_line( contents: String, prefix: String, @@ -433,6 +480,21 @@ fn extract_prefixed_line( |> find_prefixed_line(prefix) } +fn extract_json_string( + contents: String, + prefix: String, + error_message: String, +) -> Result(String, String) { + case string.split_once(contents, prefix) { + Ok(#(_, suffix)) -> + case string.split_once(suffix, "\"") { + Ok(#(value, _)) -> Ok(value) + Error(_) -> Error(error_message) + } + Error(_) -> Error(error_message) + } +} + fn find_prefixed_line( lines: List(String), prefix: String, diff --git a/src/night_shift/infra/dashboard_session.gleam b/src/night_shift/infra/dashboard_session.gleam index aba09a9..8864ea0 100644 --- a/src/night_shift/infra/dashboard_session.gleam +++ b/src/night_shift/infra/dashboard_session.gleam @@ -1,55 +1,15 @@ import gleam/io -import gleam/list import gleam/result import night_shift/dashboard -import night_shift/journal import night_shift/system -import night_shift/types -import night_shift/usecase/support/environment -import night_shift/usecase/support/repo_guard -import night_shift/usecase/support/runs -pub fn start( - repo_root: String, - selector: types.RunSelector, - config: types.Config, -) -> Result(Nil, String) { - use run <- result.try(runs.load_start_run(repo_root, selector)) - use warnings <- result.try(repo_guard.ensure_clean_repo_for_start(repo_root)) - use active_run <- result.try(journal.activate_run(run)) - use session <- result.try(dashboard.start_start_session( - repo_root, - active_run.run_id, - active_run, - config, - )) - warnings |> list.each(io.println) - io.println(render_dashboard_summary(session.url, active_run.run_id)) +pub fn start(repo_root: String) -> Result(Nil, String) { + use session <- result.try(dashboard.start_session(repo_root)) + io.println(render_dashboard_summary(session.url)) system.wait_forever() Ok(Nil) } -pub fn resume( - repo_root: String, - selector: types.RunSelector, - config: types.Config, -) -> Result(Nil, String) { - use #(saved_run, _) <- result.try(journal.load(repo_root, selector)) - use _ <- result.try(environment.ensure_saved_environment_is_valid( - repo_root, - saved_run.environment_name, - )) - use session <- result.try(dashboard.start_resume_session( - repo_root, - saved_run.run_id, - saved_run, - config, - )) - io.println(render_dashboard_summary(session.url, saved_run.run_id)) - system.wait_forever() - Ok(Nil) -} - -fn render_dashboard_summary(url: String, run_id: String) -> String { - "Dashboard: " <> url <> "\n" <> "Run: " <> run_id +fn render_dashboard_summary(url: String) -> String { + "Dash: " <> url } diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index bf9a222..b11e4a0 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -687,6 +687,7 @@ pub fn default_config() -> Config { /// Parsed CLI commands for the operator-facing executable. pub type Command { + Dash Start(run: RunSelector, ui_enabled: Bool) Init(agent_overrides: AgentOverrides, generate_setup: Bool, assume_yes: Bool) Reset(assume_yes: Bool, force: Bool) diff --git a/src/night_shift_dashboard_server.erl b/src/night_shift_dashboard_server.erl index 7455d4f..5c17238 100644 --- a/src/night_shift_dashboard_server.erl +++ b/src/night_shift_dashboard_server.erl @@ -1,28 +1,28 @@ -module(night_shift_dashboard_server). --export([start_view_session/2, start_start_session/4, start_resume_session/4, stop_session/1, http_get/1]). +-export([start_session/1, stop_session/1, http_get/1, http_post/2]). -define(TABLE, night_shift_dashboard_sessions). +-define(DEFAULT_START_PORT, 8787). +-define(PORT_WINDOW, 20). -define(HOST, "127.0.0.1"). --define(START_PORT, 8787). --define(END_PORT, 8797). -start_view_session(RepoRoot, InitialRunId) -> - start_session(RepoRoot, InitialRunId, undefined). - -start_start_session(RepoRoot, InitialRunId, Run, Config) -> - start_session( - RepoRoot, - InitialRunId, - fun() -> run_start(Run, Config) end - ). - -start_resume_session(RepoRoot, InitialRunId, Run, Config) -> - start_session( - RepoRoot, - InitialRunId, - fun() -> run_resume(Run, Config) end - ). +start_session(RepoRoot) -> + ensure_table(), + StartPort = preferred_start_port(), + case listen(StartPort, StartPort + ?PORT_WINDOW - 1) of + {ok, Listener, Port} -> + Handle = integer_to_binary(erlang:unique_integer([positive, monotonic])), + ServerPid = + spawn(fun() -> + process_flag(trap_exit, true), + accept_loop(Listener, RepoRoot) + end), + ets:insert(?TABLE, {Handle, ServerPid}), + {ok, {session, build_url(Port), Handle}}; + {error, Message} -> + {error, Message} + end. stop_session({session, _Url, Handle}) -> ensure_table(), @@ -45,23 +45,33 @@ http_get(Url) -> {error, unicode:characters_to_binary(io_lib:format("~p", [Reason]))} end. -start_session(RepoRoot, InitialRunId, Runner) -> - ensure_table(), - case listen(?START_PORT) of - {ok, Listener, Port} -> - Handle = integer_to_binary(erlang:unique_integer([positive, monotonic])), - ServerPid = - spawn(fun() -> - maybe_spawn_runner(Runner), - accept_loop(Listener, RepoRoot, InitialRunId) - end), - ets:insert(?TABLE, {Handle, ServerPid}), - {ok, {session, build_url(Port), Handle}}; - {error, Message} -> - {error, Message} +http_post(Url, Body) -> + application:ensure_all_started(inets), + Request = + {binary_to_list(Url), [], "application/json; charset=utf-8", Body}, + case httpc:request(post, Request, [], [{body_format, binary}]) of + {ok, {{_, Status, _}, _Headers, ResponseBody}} when Status >= 200, Status < 300 -> + {ok, ResponseBody}; + {ok, {{_, Status, _}, _Headers, ResponseBody}} -> + {error, <<(integer_to_binary(Status))/binary, ": ", ResponseBody/binary>>}; + {error, Reason} -> + {error, unicode:characters_to_binary(io_lib:format("~p", [Reason]))} + end. + +preferred_start_port() -> + case os:getenv("NIGHT_SHIFT_PORT_BASE") of + false -> + ?DEFAULT_START_PORT; + Value -> + case string:to_integer(Value) of + {Int, _} when Int > 0 -> + Int; + _ -> + ?DEFAULT_START_PORT + end end. -listen(Port) when Port =< ?END_PORT -> +listen(Port, EndPort) when Port =< EndPort -> case gen_tcp:listen( Port, [binary, {active, false}, {packet, raw}, {ip, {127, 0, 0, 1}}, {reuseaddr, true}] @@ -69,93 +79,297 @@ listen(Port) when Port =< ?END_PORT -> {ok, Listener} -> {ok, Listener, Port}; {error, eaddrinuse} -> - listen(Port + 1); + listen(Port + 1, EndPort); {error, Reason} -> - {error, unicode:characters_to_binary(io_lib:format("Unable to start dashboard server: ~p", [Reason]))} + {error, unicode:characters_to_binary(io_lib:format("Unable to start dash server: ~p", [Reason]))} end; -listen(_) -> - {error, <<"Unable to start dashboard server on 127.0.0.1:8787-8797.">>}. +listen(StartPort, EndPort) -> + {error, + unicode:characters_to_binary( + io_lib:format("Unable to start dash server on 127.0.0.1:~B-~B.", [StartPort, EndPort]) + )}. build_url(Port) -> <<"http://127.0.0.1:", (integer_to_binary(Port))/binary>>. -maybe_spawn_runner(undefined) -> - ok; -maybe_spawn_runner(Runner) -> - spawn(fun() -> Runner() end), - ok. +ensure_table() -> + case ets:info(?TABLE) of + undefined -> + ets:new(?TABLE, [named_table, public, set]), + ok; + _ -> + ok + end. -accept_loop(Listener, RepoRoot, InitialRunId) -> +accept_loop(Listener, RepoRoot) -> case gen_tcp:accept(Listener) of {ok, Socket} -> - spawn(fun() -> handle_client(Socket, RepoRoot, InitialRunId) end), - accept_loop(Listener, RepoRoot, InitialRunId); + spawn(fun() -> handle_client(Socket, RepoRoot) end), + accept_loop(Listener, RepoRoot); {error, closed} -> ok; {error, _Reason} -> ok end. -handle_client(Socket, RepoRoot, InitialRunId) -> - Request = read_request(Socket, <<>>), +handle_client(Socket, RepoRoot) -> + Request = read_request(Socket), case parse_request(Request) of - {ok, <<"GET">>, <<"/">>} -> - reply(Socket, 200, <<"text/html; charset=utf-8">>, night_shift@dashboard:index_html(InitialRunId)); - {ok, <<"GET">>, <<"/api/runs">>} -> - case night_shift@dashboard:runs_json(RepoRoot) of - {ok, Payload} -> - reply(Socket, 200, <<"application/json; charset=utf-8">>, Payload); - {error, Message} -> - reply(Socket, 500, <<"text/plain; charset=utf-8">>, Message) - end; - {ok, <<"GET">>, <<"/api/runs/", RunId/binary>>} -> - case night_shift@dashboard:run_json(RepoRoot, uri_string:unquote(RunId)) of - {ok, Payload} -> - reply(Socket, 200, <<"application/json; charset=utf-8">>, Payload); - {error, Message} -> - reply(Socket, 404, <<"text/plain; charset=utf-8">>, Message) - end; - {ok, <<"GET">>, _Path} -> + {ok, <<"GET">>, <<"/">>, _Query, _Body} -> + reply(Socket, 200, <<"text/html; charset=utf-8">>, night_shift@dashboard:index_html(<<>>)); + {ok, <<"GET">>, <<"/api/bootstrap">>, Query, _Body} -> + reply_dashboard_json(Socket, 200, night_shift@dashboard:bootstrap_json(RepoRoot, query_value(Query, <<"run_id">>))); + {ok, <<"GET">>, <<"/api/audit">>, Query, _Body} -> + reply_dashboard_json(Socket, 200, night_shift@dashboard:audit_json(RepoRoot, query_value(Query, <<"run_id">>))); + {ok, <<"GET">>, <<"/api/provider-models">>, Query, _Body} -> + reply_dashboard_json(Socket, 200, night_shift@dashboard:provider_models_json(RepoRoot, query_value(Query, <<"provider">>))); + {ok, <<"GET">>, <<"/api/artifacts">>, Query, _Body} -> + serve_artifact(Socket, RepoRoot, query_value(Query, <<"path">>)); + {ok, <<"GET">>, <<"/api/events">>, Query, _Body} -> + serve_events(Socket, RepoRoot, query_value(Query, <<"run_id">>)); + {ok, <<"POST">>, <<"/api/commands/", Command/binary>>, _Query, Body} -> + reply_dashboard_json(Socket, 200, night_shift@dashboard:command_json(RepoRoot, Command, Body)); + {ok, <<"GET">>, _Path, _Query, _Body} -> reply(Socket, 404, <<"text/plain; charset=utf-8">>, <<"Not found">>); - {ok, _Method, _Path} -> + {ok, _Method, _Path, _Query, _Body} -> reply(Socket, 405, <<"text/plain; charset=utf-8">>, <<"Method not allowed">>); error -> reply(Socket, 400, <<"text/plain; charset=utf-8">>, <<"Bad request">>) - end, - gen_tcp:close(Socket). + end. + +read_request(Socket) -> + read_request(Socket, <<>>, undefined). -read_request(Socket, Acc) -> - case gen_tcp:recv(Socket, 0) of - {ok, Data} -> - Next = <>, - case binary:match(Next, <<"\r\n\r\n">>) of - {_, _} -> Next; - nomatch when byte_size(Next) < 65536 -> read_request(Socket, Next); - nomatch -> Next +read_request(Socket, Acc, ExpectedBodyLength) -> + case has_complete_request(Acc, ExpectedBodyLength) of + true -> + Acc; + false -> + case gen_tcp:recv(Socket, 0) of + {ok, Data} -> + Next = <>, + read_request(Socket, Next, determine_expected_body_length(Next, ExpectedBodyLength)); + {error, _Reason} -> + Acc + end + end. + +determine_expected_body_length(_Request, Expected) when Expected =/= undefined -> + Expected; +determine_expected_body_length(Request, undefined) -> + case split_headers_body(Request) of + {ok, Headers, _Body} -> + content_length(Headers); + error -> + undefined + end. + +has_complete_request(Request, ExpectedBodyLength) -> + case split_headers_body(Request) of + {ok, _Headers, Body} -> + case ExpectedBodyLength of + undefined -> + true; + Length -> + byte_size(Body) >= Length end; - {error, _Reason} -> - Acc + error -> + false + end. + +split_headers_body(Request) -> + case binary:match(Request, <<"\r\n\r\n">>) of + {Index, 4} -> + HeaderSize = Index, + BodyStart = Index + 4, + BodySize = byte_size(Request) - BodyStart, + <> = Request, + {ok, Headers, Body}; + nomatch -> + error + end. + +content_length(Headers) -> + Lines = binary:split(Headers, <<"\r\n">>, [global]), + content_length_from_lines(Lines). + +content_length_from_lines([]) -> + 0; +content_length_from_lines([Line | Rest]) -> + Lower = to_binary(string:lowercase(binary_to_list(Line))), + case binary:split(Lower, <<":">>, [global]) of + [<<"content-length">>, RawValue] -> + parse_length(to_binary(string:trim(binary_to_list(RawValue)))); + _ -> + content_length_from_lines(Rest) + end. + +parse_length(Value) -> + case string:to_integer(binary_to_list(Value)) of + {Int, _} when Int >= 0 -> + Int; + _ -> + 0 end. parse_request(Request) -> - case binary:split(Request, <<"\r\n">>, [global]) of - [RequestLine | _] -> - case binary:split(RequestLine, <<" ">>, [global]) of - [Method, RawPath, _Version] -> - {ok, Method, strip_query(RawPath)}; + case split_headers_body(Request) of + {ok, Headers, Body} -> + case binary:split(Headers, <<"\r\n">>, [global]) of + [RequestLine | _] -> + case binary:split(RequestLine, <<" ">>, [global]) of + [Method, RawPath, _Version] -> + {ok, Method, strip_query(RawPath), parse_query(RawPath), trim_body(Body)}; + _ -> + error + end; _ -> error end; - _ -> + error -> error end. +trim_body(Body) -> + Body. + strip_query(Path) -> case binary:split(Path, <<"?">>) of [Clean | _] -> Clean; [] -> Path end. +parse_query(Path) -> + case binary:split(Path, <<"?">>) of + [_PathOnly, QueryString] -> + parse_query_pairs(binary:split(QueryString, <<"&">>, [global]), #{}); + _ -> + #{} + end. + +parse_query_pairs([], Acc) -> + Acc; +parse_query_pairs([<<>> | Rest], Acc) -> + parse_query_pairs(Rest, Acc); +parse_query_pairs([Pair | Rest], Acc) -> + case binary:split(Pair, <<"=">>, [global]) of + [Key, Value] -> + parse_query_pairs(Rest, Acc#{uri_string:unquote(Key) => uri_string:unquote(Value)}); + [Key] -> + parse_query_pairs(Rest, Acc#{uri_string:unquote(Key) => <<>>}); + _ -> + parse_query_pairs(Rest, Acc) + end. + +query_value(Query, Key) -> + maps:get(Key, Query, <<>>). + +reply_dashboard_json(Socket, SuccessStatus, Result) -> + case Result of + {ok, Payload} -> + reply(Socket, SuccessStatus, <<"application/json; charset=utf-8">>, Payload); + {error, Payload} -> + reply(Socket, 400, <<"application/json; charset=utf-8">>, Payload) + end. + +serve_artifact(Socket, _RepoRoot, <<>>) -> + reply(Socket, 400, <<"text/plain; charset=utf-8">>, <<"Missing artifact path">>); +serve_artifact(Socket, RepoRoot, Path) -> + case artifact_path_allowed(RepoRoot, Path) of + false -> + reply(Socket, 403, <<"text/plain; charset=utf-8">>, <<"Artifact path is outside the current repository.">>); + true -> + case file:read_file(binary_to_list(Path)) of + {ok, Body} -> + reply(Socket, 200, artifact_content_type(Path), Body); + {error, enoent} -> + reply(Socket, 404, <<"text/plain; charset=utf-8">>, <<"Artifact not found">>); + {error, Reason} -> + reply( + Socket, + 500, + <<"text/plain; charset=utf-8">>, + unicode:characters_to_binary(io_lib:format("Unable to read artifact: ~p", [Reason])) + ) + end + end. + +artifact_path_allowed(RepoRoot, Path) -> + RepoAbs = filename:absname(binary_to_list(RepoRoot)), + PathAbs = filename:absname(binary_to_list(Path)), + PathAbs =:= RepoAbs orelse lists:prefix(RepoAbs ++ "/", PathAbs). + +artifact_content_type(Path) -> + case filename:extension(binary_to_list(Path)) of + ".json" -> <<"application/json; charset=utf-8">>; + ".jsonl" -> <<"application/json; charset=utf-8">>; + ".html" -> <<"text/html; charset=utf-8">>; + ".md" -> <<"text/markdown; charset=utf-8">>; + ".toml" -> <<"text/plain; charset=utf-8">>; + ".log" -> <<"text/plain; charset=utf-8">>; + ".env" -> <<"text/plain; charset=utf-8">>; + _ -> <<"text/plain; charset=utf-8">> + end. + +serve_events(Socket, RepoRoot, RequestedRunId) -> + Headers = + << + "HTTP/1.1 200 OK\r\n", + "Content-Type: text/event-stream; charset=utf-8\r\n", + "Cache-Control: no-store\r\n", + "Connection: keep-alive\r\n\r\n" + >>, + ok = gen_tcp:send(Socket, Headers), + case night_shift@dashboard:bootstrap_json(RepoRoot, RequestedRunId) of + {ok, Payload} -> + case send_sse(Socket, <<"bootstrap">>, Payload) of + ok -> + event_loop(Socket, RepoRoot, RequestedRunId, Payload, 0); + _ -> + ok + end; + {error, Payload} -> + _ = send_sse(Socket, <<"error">>, Payload), + gen_tcp:close(Socket) + end. + +event_loop(Socket, RepoRoot, RequestedRunId, PreviousPayload, IdleTicks) -> + timer:sleep(500), + case night_shift@dashboard:bootstrap_json(RepoRoot, RequestedRunId) of + {ok, Payload} when Payload =/= PreviousPayload -> + case send_sse(Socket, <<"state">>, Payload) of + ok -> + event_loop(Socket, RepoRoot, RequestedRunId, Payload, 0); + _ -> + gen_tcp:close(Socket) + end; + {ok, Payload} -> + case maybe_send_keepalive(Socket, IdleTicks) of + ok -> + event_loop(Socket, RepoRoot, RequestedRunId, Payload, IdleTicks + 1); + _ -> + gen_tcp:close(Socket) + end; + {error, Payload} -> + _ = send_sse(Socket, <<"error">>, Payload), + gen_tcp:close(Socket) + end. + +maybe_send_keepalive(Socket, IdleTicks) when IdleTicks >= 9 -> + gen_tcp:send(Socket, <<": keep-alive\n\n">>); +maybe_send_keepalive(_Socket, _IdleTicks) -> + ok. + +send_sse(Socket, Event, Payload) -> + Data = escape_sse_payload(Payload), + gen_tcp:send( + Socket, + <<"event: ", Event/binary, "\n", "data: ", Data/binary, "\n\n">> + ). + +escape_sse_payload(Payload) -> + re:replace(Payload, <<"\n">>, <<"\ndata: ">>, [global, {return, binary}]). + reply(Socket, StatusCode, ContentType, Body) -> StatusLine = status_line(StatusCode), Response = @@ -167,47 +381,17 @@ reply(Socket, StatusCode, ContentType, Body) -> "Connection: close\r\n\r\n", Body/binary >>, - ok = gen_tcp:send(Socket, Response). + ok = gen_tcp:send(Socket, Response), + gen_tcp:close(Socket). status_line(200) -> <<"200 OK">>; status_line(400) -> <<"400 Bad Request">>; +status_line(403) -> <<"403 Forbidden">>; status_line(404) -> <<"404 Not Found">>; status_line(405) -> <<"405 Method Not Allowed">>; status_line(500) -> <<"500 Internal Server Error">>. -run_start(Run, Config) -> - case night_shift@orchestrator:start(Run, Config) of - {ok, _CompletedRun} -> - ok; - {error, Message} -> - mark_failed(Run, Message) - end. - -run_resume(Run, Config) -> - case night_shift@orchestrator:resume(Run, Config) of - {ok, _CompletedRun} -> - ok; - {error, Message} -> - mark_failed(Run, Message) - end. - -mark_failed(Run, Message) -> - RepoRoot = erlang:element(3, Run), - RunId = erlang:element(2, Run), - case night_shift@journal:load(RepoRoot, {run_id, RunId}) of - {ok, {LatestRun, _Events}} -> - _ = night_shift@journal:mark_status(LatestRun, run_failed, Message), - ok; - {error, _} -> - _ = night_shift@journal:mark_status(Run, run_failed, Message), - ok - end. - -ensure_table() -> - case ets:info(?TABLE) of - undefined -> - ets:new(?TABLE, [named_table, public, set]), - ok; - _ -> - ok - end. +to_binary(Value) when is_binary(Value) -> + Value; +to_binary(Value) -> + unicode:characters_to_binary(Value). diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index 59ab55a..5b1c16b 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -66,9 +66,13 @@ pub fn parse_status_defaults_to_latest_test() { let assert Ok(types.Status(types.LatestRun)) = cli.parse(["status"]) } -pub fn parse_start_command_with_ui_test() { - let assert Ok(types.Start(types.LatestRun, True)) = - cli.parse(["start", "--ui"]) +pub fn parse_dash_command_test() { + let assert Ok(types.Dash) = cli.parse(["dash"]) +} + +pub fn parse_start_rejects_ui_test() { + let assert Error(message) = cli.parse(["start", "--ui"]) + assert message == "Unsupported start flag: --ui" } pub fn parse_start_command_without_brief_test() { @@ -84,9 +88,10 @@ pub fn parse_resolve_defaults_to_latest_test() { let assert Ok(types.Resolve(types.LatestRun)) = cli.parse(["resolve"]) } -pub fn parse_resume_command_with_ui_test() { - let assert Ok(types.Resume(types.RunId("run-123"), True, False)) = +pub fn parse_resume_rejects_ui_test() { + let assert Error(message) = cli.parse(["resume", "--run", "run-123", "--ui"]) + assert message == "Unsupported flag: --ui" } pub fn parse_resume_explain_command_test() { diff --git a/test/night_shift_dashboard_demo_test.gleam b/test/night_shift_dashboard_demo_test.gleam index edd8dec..d5dc3c6 100644 --- a/test/night_shift_dashboard_demo_test.gleam +++ b/test/night_shift_dashboard_demo_test.gleam @@ -91,18 +91,17 @@ pub fn dashboard_start_session_tracks_completed_run_test() { system.set_env("PATH", bin_dir <> ":" <> old_path) system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) system.set_env("XDG_STATE_HOME", state_home) - - let config = - types.Config( - ..types.default_config(), - verification_commands: [], - max_workers: 1, - ) + let assert Ok(_) = support.initialize_project_home(repo_root) let assert Ok(run) = support.planned_run(repo_root, brief_path, types.Codex, 1) - let assert Ok(session) = - dashboard.start_start_session(repo_root, run.run_id, run, config) + let assert Ok(session) = dashboard.start_session(repo_root) + let assert Ok(_) = + support.post_dash_command( + session.url, + "start", + "{\"run_id\":\"" <> run.run_id <> "\"}", + ) let final_payload = support.wait_for_run_payload(session.url, run.run_id, 40) system.set_env("PATH", old_path) @@ -114,7 +113,10 @@ pub fn dashboard_start_session_tracks_completed_run_test() { does: final_payload, contain: "\"status\":\"completed\"", ) - assert string.contains(does: final_payload, contain: "\"pr_number\":\"1\"") + assert string.contains( + does: final_payload, + contain: "\"number\":\"1\"", + ) let _ = dashboard.stop_session(session) let _ = simplifile.delete(file_or_dir_at: base_dir) @@ -167,7 +169,7 @@ pub fn demo_run_succeeds_with_ui_test() { assert string.contains( does: summary, - contain: "Validated UI flows: plan, start --ui, dashboard payload, status", + contain: "Validated UI flows: dash, bootstrap, start, status", ) assert string.contains(does: summary, contain: "Dashboard: http://127.0.0.1:") assert string.contains( diff --git a/test/night_shift_persistence_provider_test.gleam b/test/night_shift_persistence_provider_test.gleam index b875f37..8732332 100644 --- a/test/night_shift_persistence_provider_test.gleam +++ b/test/night_shift_persistence_provider_test.gleam @@ -252,16 +252,16 @@ pub fn dashboard_server_serves_run_data_test() { let assert Ok(_) = simplifile.create_directory_all(base_dir) let assert Ok(_) = simplifile.write("# Brief", to: brief_path) let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) - let assert Ok(session) = dashboard.start_view_session(repo_root, run.run_id) + let assert Ok(session) = dashboard.start_session(repo_root) system.sleep(100) let assert Ok(index_html) = dashboard.http_get(session.url) - let assert Ok(runs_payload) = dashboard.http_get(session.url <> "/api/runs") + let assert Ok(runs_payload) = dashboard.http_get(session.url <> "/api/bootstrap") let assert Ok(run_payload) = - dashboard.http_get(session.url <> "/api/runs/" <> run.run_id) + dashboard.http_get(session.url <> "/api/audit?run_id=" <> run.run_id) - assert string.contains(does: index_html, contain: "Night Shift Dashboard") + assert string.contains(does: index_html, contain: "Night Shift Dash") assert string.contains(does: runs_payload, contain: run.run_id) assert string.contains( does: run_payload, diff --git a/test/night_shift_test_support.gleam b/test/night_shift_test_support.gleam index 811e64c..2870cc6 100644 --- a/test/night_shift_test_support.gleam +++ b/test/night_shift_test_support.gleam @@ -998,7 +998,7 @@ pub fn wait_for_run_payload( run_id: String, attempts: Int, ) -> String { - let url = base_url <> "/api/runs/" <> run_id + let url = base_url <> "/api/audit?run_id=" <> run_id case attempts { value if value <= 0 -> dashboard.http_get(url) @@ -1023,6 +1023,14 @@ pub fn wait_for_run_payload( } } +pub fn post_dash_command( + base_url: String, + command: String, + body: String, +) -> Result(String, String) { + dashboard.http_post(base_url <> "/api/commands/" <> command, body) +} + pub fn write_fake_gh(path: String) -> Result(Nil, simplifile.FileError) { simplifile.write( "#!/bin/sh\n"