From ba2a1a8da7f9b113ceafd503efd878fc08c5c1f9 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:02:48 -0700 Subject: [PATCH 01/13] Document trust-first doctor and provenance surfaces --- .codex/skills/qa-night-shift/SKILL.md | 20 +- README.md | 9 +- docs/README.md | 6 +- docs/getting-started.md | 13 +- docs/index.md | 6 +- docs/run-lifecycle.md | 23 + docs/state-and-artifacts.md | 10 + src/night_shift/app.gleam | 34 +- src/night_shift/cli.gleam | 51 +- src/night_shift/dashboard.gleam | 19 +- src/night_shift/domain/confidence.gleam | 262 ++++++++++ src/night_shift/domain/provenance.gleam | 590 +++++++++++++++++++++++ src/night_shift/domain/report.gleam | 8 + src/night_shift/infra/run_store.gleam | 4 +- src/night_shift/types.gleam | 49 +- src/night_shift/usecase/doctor.gleam | 351 ++++++++++++++ src/night_shift/usecase/plan.gleam | 14 +- src/night_shift/usecase/provenance.gleam | 25 + src/night_shift/usecase/render.gleam | 8 + src/night_shift/usecase/result.gleam | 2 + src/night_shift/usecase/status.gleam | 6 + test/night_shift_cli_config_test.gleam | 21 +- test/trust_surface_test.gleam | 243 ++++++++++ 23 files changed, 1748 insertions(+), 26 deletions(-) create mode 100644 src/night_shift/domain/confidence.gleam create mode 100644 src/night_shift/domain/provenance.gleam create mode 100644 src/night_shift/usecase/doctor.gleam create mode 100644 src/night_shift/usecase/provenance.gleam create mode 100644 test/trust_surface_test.gleam diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 5eebb72..8ebda22 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -1,6 +1,6 @@ --- name: qa-night-shift -description: Use when the user wants to QA test Night Shift against a user-specified scratch repo path, install the current worktree CLI, and run an approval-gated real-provider pass to validate init/plan/start/status/report/resolve/resume behavior. +description: Use when the user wants to QA test Night Shift against a user-specified scratch repo path, install the current worktree CLI, and run an approval-gated real-provider pass to validate init/plan/start/status/report/provenance/doctor/resolve/resume behavior. --- # QA Night Shift @@ -59,7 +59,10 @@ real inference spend. - If it does look like an intentional testing target, proceed. - Even for an obvious scratch repo, do not run `night-shift plan`, `night-shift start`, `night-shift resume`, or other inference-consuming QA - steps until the user approves the presented plan. + steps until the user approves the presented plan. Read-only checks such as + `night-shift status`, `night-shift report`, `night-shift provenance`, + `night-shift doctor`, or `night-shift resume --explain` are acceptable once + the user-approved QA pass reaches the relevant state. Do not quietly assume a normal product repo is safe to use for QA. @@ -123,7 +126,10 @@ Typical flow: 5. inspect `night-shift status` 6. run `night-shift start` 7. inspect `night-shift report` -8. use `night-shift resolve` or `night-shift resume` only if the run actually +8. inspect `night-shift provenance` +9. use `night-shift doctor` or `night-shift resume --explain` before any real + resume attempt when the run was interrupted +10. use `night-shift resolve` or `night-shift resume` only if the run actually requires it For review-driven investigations, replace steps 3-4 with: @@ -167,6 +173,13 @@ In review-driven runs, pay attention to repo-state evidence: manual attention - whether `status` and `report` show payload-repair attempts, successes, and failures with usable artifact paths +- whether `status`, `report`, and the dashboard agree on the confidence posture + and its reasons +- 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`, + `resume_with_warning`, `manual_attention`, or `irrecoverable` for the actual + saved repo state Use small tasks that validate the requested behavior instead of inviting large feature work. @@ -180,6 +193,7 @@ Collect evidence from: - relevant CLI output - the current report path printed by Night Shift - run journal paths under `.night-shift/runs/` +- the `provenance.json` path and any task-specific artifact paths it surfaces - relevant logs for the failing or surprising step - PR or delivery results when they happen - any verification output tied to the run diff --git a/README.md b/README.md index 2a4b418..f08ea8c 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,16 @@ night-shift plan --notes notes/today.md night-shift start night-shift status night-shift report +night-shift provenance ``` Supporting commands round out the lifecycle: - `resolve` records answers for blocked planning decisions and replans the run +- `doctor` explains whether a saved run is safe to resume and why +- `provenance` renders a per-run evidence ledger from saved artifacts - `resume` recovers an interrupted run from saved state -- `review` reopens open Night Shift PRs as stabilization tasks +- `plan --from-reviews` folds open Night Shift PR feedback back into planning - `reset` removes repo-local Night Shift state and tracked worktrees - `--demo` runs a fixture-backed proof flow @@ -104,6 +107,7 @@ Inspect progress and outputs: ```sh night-shift status night-shift report +night-shift provenance ``` If planning blocked on manual decisions: @@ -116,6 +120,8 @@ night-shift start If Night Shift was interrupted mid-run: ```sh +night-shift doctor +night-shift resume --explain night-shift resume ``` @@ -148,4 +154,3 @@ asdf install - [Worktree Environments](docs/worktree-environments.md) - [State and Artifacts](docs/state-and-artifacts.md) - [Providers and Delivery](docs/providers-and-delivery.md) - diff --git a/docs/README.md b/docs/README.md index 41615be..c5bb0c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,8 @@ If you are new to the project, start here: - [Getting Started](getting-started.md) for install, prerequisites, and the first runnable flow - [Run Lifecycle](run-lifecycle.md) for how `plan`, `start`, `resolve`, - `resume`, `plan --from-reviews`, and `reset` fit together + `resume`, `doctor`, `provenance`, `plan --from-reviews`, and `reset` fit + together - [Configuration](configuration.md) for `config.toml` profiles and override precedence - [Worktree Environments](worktree-environments.md) for @@ -40,11 +41,14 @@ night-shift plan --notes notes/today.md night-shift start night-shift status night-shift report +night-shift provenance ``` Supporting flows handle the messier parts of reality: - `resolve` records answers for manual-attention tasks and replans in place +- `doctor` explains whether an interrupted run looks safe to resume +- `provenance` prints the run's evidence ledger - `resume` reattaches to an interrupted run - `plan --from-reviews` turns open Night Shift PR feedback into a fresh successor stack - `reset` removes Night Shift state and tracked task worktrees, but does not touch local branches or remote PRs diff --git a/docs/getting-started.md b/docs/getting-started.md index 20b1dd0..681787a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -121,11 +121,12 @@ Use these commands while a run is active or after it finishes: ```sh night-shift status night-shift report +night-shift provenance ``` -`status` prints the current run state, planning and execution agent summaries, -notes source, event count, and report location. `report` prints the current -markdown report directly. +`status` prints the current run state, confidence posture, provenance path, +and report location. `report` prints the current markdown report directly, and +`provenance` prints the run's evidence ledger from the saved artifact graph. ## Supporting Flows @@ -140,10 +141,16 @@ night-shift start If a run was interrupted, resume from the saved journal: ```sh +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 +`safe_to_resume`, `resume_with_warning`, `manual_attention`, or +`irrecoverable` before you mutate any run state. + If open Night Shift pull requests received feedback and you want a fresh replacement stack instead of in-place edits: diff --git a/docs/index.md b/docs/index.md index d858339..38592d2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,8 +33,10 @@ night-shift report ``` Use `resolve` when planning needs human decisions, `resume` when a run was -interrupted, `review` when open Night Shift PRs need stabilization, and -`reset` when you need to eject the repo-local control plane and start over. +interrupted, `doctor` or `resume --explain` when you want a dry recovery read, +`plan --from-reviews` when open Night Shift PRs need a fresh successor stack, +and `reset` when you need to eject the repo-local control plane and start +over. ## Repository diff --git a/docs/run-lifecycle.md b/docs/run-lifecycle.md index 9a56ded..b487611 100644 --- a/docs/run-lifecycle.md +++ b/docs/run-lifecycle.md @@ -58,6 +58,8 @@ and the next action becomes `night-shift start`. `resume` is the recovery path for an interrupted run: ```sh +night-shift doctor +night-shift resume --explain night-shift resume night-shift resume --run run-123 --ui ``` @@ -66,6 +68,11 @@ 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. +`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`, +`resume_with_warning`, `manual_attention`, or `irrecoverable`. + ## Review-Driven Replanning Night Shift no longer has a separate `review` execution entry point. Review @@ -100,6 +107,21 @@ it recomputes drift against the current PR tree when the run has a stored review snapshot, while the on-disk `report.md` remains the stable persisted artifact for the run. +## Provenance + +`provenance` is the operator-facing evidence ledger for a run: + +```sh +night-shift provenance +night-shift provenance --run run-123 --format json +night-shift provenance --task task-1 +``` + +Night Shift persists `./.night-shift/runs//provenance.json` alongside +`report.md`. The command normalizes the run journal, prompt artifacts, logs, +payload-repair traces, verification artifacts, worktree paths, and confidence +posture into one inspectable view. + ## Reset `reset` is the eject handle when the repo-local control plane has to go: @@ -142,6 +164,7 @@ Night Shift binds to `127.0.0.1`, prefers port `8787`, 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 diff --git a/docs/state-and-artifacts.md b/docs/state-and-artifacts.md index 6418fee..1dbb2ad 100644 --- a/docs/state-and-artifacts.md +++ b/docs/state-and-artifacts.md @@ -33,6 +33,7 @@ Each run directory contains durable state for one run: - `state.json` - `events.jsonl` - `report.md` +- `provenance.json` - `logs/` - `worktrees/` @@ -53,6 +54,11 @@ The run record itself stores: - task list and task states - timestamps and current run status +`provenance.json` is the normalized evidence ledger for the run. It reuses the +saved run state plus artifact paths under `logs/` to record planning +provenance, prompt and payload traces, verification evidence, touched files, +worktree paths, PR linkage, and confidence posture. + ## Planning Artifacts Planning writes artifacts under `./.night-shift/planning//`. Those @@ -88,6 +94,10 @@ review-driven runs: it refreshes repo-state drift against the current open PR tree when a stored snapshot exists, so its live output is authoritative for current drift while `report.md` remains durable and offline-readable. +Likewise, the persisted `provenance.json` is the stable audit artifact for the +run, while `night-shift provenance` can render the same evidence in markdown or +refresh live review drift in JSON output. + Task-level provider logs and prompt files live under each run's `logs/` directory. diff --git a/src/night_shift/app.gleam b/src/night_shift/app.gleam index 46dd88c..cf60390 100644 --- a/src/night_shift/app.gleam +++ b/src/night_shift/app.gleam @@ -25,8 +25,10 @@ import night_shift/repo_state_runtime import night_shift/report import night_shift/system import night_shift/types +import night_shift/usecase/doctor as doctor_usecase import night_shift/usecase/init as init_usecase import night_shift/usecase/plan as plan_usecase +import night_shift/usecase/provenance as provenance_usecase import night_shift/usecase/render as usecase_render import night_shift/usecase/reset as reset_usecase import night_shift/usecase/resolve as resolve_usecase @@ -131,9 +133,13 @@ fn run_initialized_command( 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) -> + 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) -> io.println(resume(repo_root, run, config)) - types.Resume(run, True) -> resume_with_ui(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)) _ -> io.println("Unsupported command.") } } @@ -276,6 +282,30 @@ fn resume( } } +fn doctor( + repo_root: String, + run: types.RunSelector, + config: types.Config, +) -> String { + case doctor_usecase.execute(repo_root, run, config) { + Ok(rendered) -> rendered + Error(message) -> message + } +} + +fn provenance( + repo_root: String, + run: types.RunSelector, + task_id: Option(String), + format: types.ProvenanceFormat, + config: types.Config, +) -> String { + case provenance_usecase.execute(repo_root, run, task_id, format, config) { + Ok(rendered) -> rendered + Error(message) -> message + } +} + fn stringify_notifiers(notifiers: List(types.NotifierName)) -> String { notifiers |> list.map(types.notifier_to_string) diff --git a/src/night_shift/cli.gleam b/src/night_shift/cli.gleam index 77ef351..5694ba1 100644 --- a/src/night_shift/cli.gleam +++ b/src/night_shift/cli.gleam @@ -18,8 +18,10 @@ pub fn usage() -> String { <> " start [--run |latest] [--ui]\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]\n" + <> " resume [--run |latest] [--ui|--explain]\n" } /// Parse raw command-line arguments into a `Command`. @@ -48,6 +50,8 @@ pub fn parse(args: List(String)) -> Result(types.Command, String) { ["start", ..rest] -> parse_start(rest) ["status", ..rest] -> parse_run_lookup(rest, types.Status) ["report", ..rest] -> parse_run_lookup(rest, types.Report) + ["provenance", ..rest] -> parse_provenance(rest) + ["doctor", ..rest] -> parse_run_lookup(rest, types.Doctor) ["resolve", ..rest] -> parse_run_lookup(rest, types.Resolve) ["resume", ..rest] -> parse_resume(rest) ["review", ..] -> @@ -256,21 +260,56 @@ fn parse_start_flags( } fn parse_resume(args: List(String)) -> Result(types.Command, String) { - parse_resume_flags(args, types.LatestRun, False) + parse_resume_flags(args, types.LatestRun, False, 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)) + [] -> + case ui_enabled && explain_only { + True -> Error("`resume --explain` cannot be combined with `--ui`.") + False -> 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) + } +} + +fn parse_provenance(args: List(String)) -> Result(types.Command, String) { + parse_provenance_flags(args, types.LatestRun, None, types.ProvenanceMarkdown) +} + +fn parse_provenance_flags( + args: List(String), + run: types.RunSelector, + task_id: Option(String), + format: types.ProvenanceFormat, +) -> Result(types.Command, String) { + case args { + [] -> Ok(types.Provenance(run, task_id, format)) ["--run", "latest", ..rest] -> - parse_resume_flags(rest, types.LatestRun, ui_enabled) + parse_provenance_flags(rest, types.LatestRun, task_id, format) ["--run", run_id, ..rest] -> - parse_resume_flags(rest, types.RunId(run_id), ui_enabled) - ["--ui", ..rest] -> parse_resume_flags(rest, run, True) + parse_provenance_flags(rest, types.RunId(run_id), task_id, format) + ["--task", next_task_id, ..rest] -> + parse_provenance_flags(rest, run, Some(next_task_id), format) + ["--format", "json", ..rest] -> + parse_provenance_flags(rest, run, task_id, types.ProvenanceJson) + ["--format", "md", ..rest] -> + parse_provenance_flags(rest, run, task_id, types.ProvenanceMarkdown) + ["--format", raw_format, ..] -> + Error("Unsupported provenance format: " <> raw_format) [flag, ..] -> Error("Unsupported flag: " <> flag) } } diff --git a/src/night_shift/dashboard.gleam b/src/night_shift/dashboard.gleam index 3c625c0..03cbd2b 100644 --- a/src/night_shift/dashboard.gleam +++ b/src/night_shift/dashboard.gleam @@ -5,6 +5,8 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import night_shift/config +import night_shift/domain/confidence +import night_shift/domain/provenance import night_shift/domain/review_run_projection import night_shift/journal import night_shift/project @@ -132,7 +134,7 @@ pub fn index_html(initial_run_id: String) -> String { <> " ['Run ID', run.run_id], ['Status', run.status], ['Planning profile', run.planning_agent.profile_name], ['Planning provider', run.planning_agent.provider],\n" <> " ['Planning model', run.planning_agent.model || 'default'], ['Planning reasoning', run.planning_agent.reasoning || 'default'], ['Execution profile', run.execution_agent.profile_name], ['Execution provider', run.execution_agent.provider],\n" <> " ['Execution model', run.execution_agent.model || 'default'], ['Execution reasoning', run.execution_agent.reasoning || 'default'], ['Repo', run.repo_root], ['Created', run.created_at],\n" - <> " ['Updated', run.updated_at], ['Brief', run.brief_path], ['Max workers', String(run.max_workers)]\n" + <> " ['Updated', run.updated_at], ['Brief', run.brief_path], ['Provenance', run.provenance_path], ['Confidence', run.confidence_posture], ['Confidence reasons', (run.confidence_reasons || []).join(' | ') || '—'], ['Max workers', String(run.max_workers)]\n" <> " ];\n" <> " if (run.repo_state) {\n" <> " fields.push(['Open PRs', String(run.repo_state.open_pr_count)]);\n" @@ -228,10 +230,11 @@ pub fn run_json(repo_root: String, run_id: String) -> Result(String, String) { 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) Ok( json.object([ - #("run", run_detail_json(run, review_projection)), + #("run", run_detail_json(run, review_projection, confidence_assessment)), #("events", json.array(events, event_json)), #("report", json.string(rendered_report)), ]) @@ -254,6 +257,7 @@ fn run_summary_json(run: types.RunRecord) -> json.Json { fn run_detail_json( run: types.RunRecord, review_projection: Option(review_run_projection.ReviewRunProjection), + confidence_assessment: types.ConfidenceAssessment, ) -> json.Json { json.object([ #("run_id", json.string(run.run_id)), @@ -261,8 +265,19 @@ fn run_detail_json( #("run_path", json.string(run.run_path)), #("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)), + #( + "confidence_posture", + json.string(types.confidence_posture_to_string( + confidence_assessment.posture, + )), + ), + #( + "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)), diff --git a/src/night_shift/domain/confidence.gleam b/src/night_shift/domain/confidence.gleam new file mode 100644 index 0000000..5336506 --- /dev/null +++ b/src/night_shift/domain/confidence.gleam @@ -0,0 +1,262 @@ +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import night_shift/repo_state_runtime +import night_shift/types +import simplifile + +pub fn assess( + run: types.RunRecord, + events: List(types.RunEvent), + repo_state_view: Option(repo_state_runtime.RepoStateView), +) -> types.ConfidenceAssessment { + let severe = severe_reasons(run, events) + let moderate = moderate_reasons(events, repo_state_view) + let positive = positive_reasons(run, events) + + case severe { + [_, ..] -> + types.ConfidenceAssessment( + posture: types.ConfidenceLow, + reasons: take_first(list.append(severe, moderate), 4), + ) + [] -> + case moderate { + [_, ..] -> + types.ConfidenceAssessment( + posture: types.ConfidenceGuarded, + reasons: take_first(list.append(moderate, positive), 4), + ) + [] -> + types.ConfidenceAssessment( + posture: types.ConfidenceHigh, + reasons: case positive { + [] -> ["No elevated-risk signals are recorded for this run."] + _ -> take_first(positive, 4) + }, + ) + } + } +} + +pub fn reasons_summary(assessment: types.ConfidenceAssessment) -> String { + case assessment.reasons { + [] -> "none" + reasons -> string.join(reasons, with: " | ") + } +} + +fn severe_reasons( + run: types.RunRecord, + events: List(types.RunEvent), +) -> List(String) { + let manual_attention_count = + run.tasks + |> list.filter(fn(task) { + types.task_requires_manual_attention(run.decisions, task) + }) + |> list.length + let failed_count = + run.tasks + |> list.filter(fn(task) { task.state == types.Failed }) + |> list.length + let missing_worktrees = + run.tasks + |> list.filter(fn(task) { task.worktree_path != "" }) + |> list.filter(fn(task) { !directory_exists(task.worktree_path) }) + |> list.length + let payload_repair_failures = event_count(events, "execution_payload_repair_failed") + let run_failed = event_count(events, "run_failed") + + [ + latest_environment_preflight_failure(events) + |> option_reason("Environment bootstrap failed."), + count_reason( + manual_attention_count, + "manual-attention task is still unresolved.", + "manual-attention tasks are still unresolved.", + ), + count_reason( + unresolved_decision_requests_count(run), + "operator decision is still unresolved.", + "operator decisions are still unresolved.", + ), + count_reason(failed_count, "task failed.", "tasks failed."), + count_reason( + payload_repair_failures, + "payload repair failed.", + "payload repairs failed.", + ), + count_reason( + missing_worktrees, + "retained worktree is missing from disk.", + "retained worktrees are missing from disk.", + ), + count_reason(run_failed, "run failure was recorded.", "run failures were recorded."), + ] + |> list.filter_map(identity_reason) +} + +fn moderate_reasons( + events: List(types.RunEvent), + repo_state_view: Option(repo_state_runtime.RepoStateView), +) -> List(String) { + let payload_warnings = event_count(events, "execution_payload_warning") + let payload_repairs = event_count(events, "execution_payload_repair_succeeded") + let prune_warnings = event_count(events, "worktree_prune_warning") + let supersession_warnings = event_count(events, "review_supersession_warning") + let repo_state_reason = case repo_state_view { + Some(view) -> + case view.drift { + repo_state_runtime.RepoStateDrifted -> + Some("Review snapshot drifted since planning.") + repo_state_runtime.RepoStateDriftUnknown(_) -> + Some("Live review snapshot refresh is unavailable.") + _ -> None + } + None -> None + } + + [ + count_reason( + payload_warnings, + "recovered execution payload was accepted.", + "recovered execution payloads were accepted.", + ), + count_reason( + payload_repairs, + "JSON-only payload repair succeeded.", + "JSON-only payload repairs succeeded.", + ), + count_reason( + prune_warnings, + "worktree prune warning was recorded.", + "worktree prune warnings were recorded.", + ), + count_reason( + supersession_warnings, + "review supersession warning was recorded.", + "review supersession warnings were recorded.", + ), + repo_state_reason, + ] + |> list.filter_map(identity_reason) +} + +fn positive_reasons( + run: types.RunRecord, + events: List(types.RunEvent), +) -> List(String) { + let pr_opened = event_count(events, "pr_opened") + let verified = event_count(events, "task_verified") + let retained_worktrees = + run.tasks + |> list.filter(fn(task) { task.worktree_path != "" }) + |> list.length + let retained_and_present = + retained_worktrees > 0 + && list.all( + run.tasks + |> list.filter(fn(task) { task.worktree_path != "" }), + fn(task) { directory_exists(task.worktree_path) }, + ) + + [ + case verified > 0 { + True -> Some("Verification passed for delivered task work.") + False -> None + }, + case pr_opened > 0 { + True -> Some("Delivered pull requests are recorded in the journal.") + False -> None + }, + case unresolved_decision_requests_count(run) == 0 { + True -> Some("No outstanding operator decisions remain.") + False -> None + }, + case retained_and_present { + True -> Some("Retained worktrees remain mounted for inspection and recovery.") + False -> None + }, + ] + |> list.filter_map(identity_reason) +} + +fn unresolved_decision_requests_count(run: types.RunRecord) -> Int { + run.tasks + |> list.map(fn(task) { + list.length(types.unresolved_decision_requests(run.decisions, task)) + }) + |> list.fold(0, fn(total, count) { total + count }) +} + +fn event_count(events: List(types.RunEvent), kind: String) -> Int { + events + |> list.filter(fn(event) { event.kind == kind }) + |> list.length +} + +fn latest_environment_preflight_failure( + events: List(types.RunEvent), +) -> Option(String) { + latest_environment_preflight_failure_loop(list.reverse(events)) +} + +fn latest_environment_preflight_failure_loop( + events: List(types.RunEvent), +) -> Option(String) { + case events { + [] -> None + [event, ..rest] -> + case event.kind == "environment_preflight_failed" { + True -> Some(event.message) + False -> latest_environment_preflight_failure_loop(rest) + } + } +} + +fn directory_exists(path: String) -> Bool { + case simplifile.read_directory(at: path) { + Ok(_) -> True + Error(_) -> False + } +} + +fn option_reason(value: Option(a), message: String) -> Option(String) { + case value { + Some(_) -> Some(message) + None -> None + } +} + +fn count_reason(count: Int, singular: String, plural: String) -> Option(String) { + case count { + 0 -> None + 1 -> Some("1 " <> singular) + _ -> Some(int.to_string(count) <> " " <> plural) + } +} + +fn identity_reason(value: Option(String)) -> Result(String, Nil) { + case value { + Some(reason) -> Ok(reason) + None -> Error(Nil) + } +} + +fn take_first(values: List(String), limit: Int) -> List(String) { + take_first_loop(values, limit, []) +} + +fn take_first_loop( + values: List(String), + remaining: Int, + acc: List(String), +) -> List(String) { + case values, remaining <= 0 { + _, True -> list.reverse(acc) + [], False -> list.reverse(acc) + [value, ..rest], False -> take_first_loop(rest, remaining - 1, [value, ..acc]) + } +} diff --git a/src/night_shift/domain/provenance.gleam b/src/night_shift/domain/provenance.gleam new file mode 100644 index 0000000..1cd43e3 --- /dev/null +++ b/src/night_shift/domain/provenance.gleam @@ -0,0 +1,590 @@ +import filepath +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import night_shift/config +import night_shift/domain/confidence +import night_shift/project +import night_shift/repo_state_runtime +import night_shift/types +import simplifile + +pub fn artifact_path(run: types.RunRecord) -> String { + filepath.join(run.run_path, "provenance.json") +} + +pub fn write_persisted( + run: types.RunRecord, + events: List(types.RunEvent), +) -> Result(Nil, String) { + let verification_commands = load_verification_commands(run.repo_root) + let repo_state_view = None + let assessment = confidence.assess(run, events, repo_state_view) + let manifest = + manifest_json(run, events, repo_state_view, verification_commands, assessment) + write_file(artifact_path(run), json.to_string(manifest)) +} + +pub fn render( + run: types.RunRecord, + events: List(types.RunEvent), + repo_state_view: Option(repo_state_runtime.RepoStateView), + task_filter: Option(String), + format: types.ProvenanceFormat, + verification_commands: List(String), +) -> Result(String, String) { + use filtered_tasks <- result.try(filter_tasks(run.tasks, task_filter)) + let filtered_run = types.RunRecord(..run, tasks: filtered_tasks) + let filtered_events = filter_events(events, task_filter) + let assessment = confidence.assess(filtered_run, filtered_events, repo_state_view) + + Ok(case format { + types.ProvenanceJson -> + manifest_json( + filtered_run, + filtered_events, + repo_state_view, + verification_commands, + assessment, + ) + |> json.to_string + types.ProvenanceMarkdown -> + render_markdown( + filtered_run, + filtered_events, + repo_state_view, + verification_commands, + assessment, + ) + }) +} + +fn render_markdown( + run: types.RunRecord, + events: List(types.RunEvent), + repo_state_view: Option(repo_state_runtime.RepoStateView), + verification_commands: List(String), + assessment: types.ConfidenceAssessment, +) -> String { + [ + "# Night Shift Provenance", + "", + "## Run", + "- Run ID: " <> run.run_id, + "- Status: " <> types.run_status_to_string(run.status), + "- Brief: " <> run.brief_path, + "- Report: " <> run.report_path, + "- Provenance artifact: " <> artifact_path(run), + "- Confidence posture: " + <> types.confidence_posture_to_string(assessment.posture), + "- Confidence reasons: " <> confidence.reasons_summary(assessment), + "- Planning provenance: " <> render_planning_provenance(run.planning_provenance), + "- Notes source: " <> render_notes_source(run.notes_source), + "- Planning artifacts: " <> render_string_list(planning_artifact_paths(run, events)), + "- Planner prompt: " <> render_optional_path(planner_prompt_path(run.run_path)), + "- Planner log: " <> render_optional_path(planner_log_path(run.run_path)), + render_review_state_markdown(run, repo_state_view), + "", + "## Tasks", + render_task_sections(run, events, verification_commands), + "", + "## Event References", + render_event_refs(events), + ] + |> list.filter(fn(line) { line != "" }) + |> string.join(with: "\n") +} + +fn manifest_json( + run: types.RunRecord, + events: List(types.RunEvent), + repo_state_view: Option(repo_state_runtime.RepoStateView), + verification_commands: List(String), + assessment: types.ConfidenceAssessment, +) -> json.Json { + json.object([ + #( + "run", + json.object([ + #("run_id", json.string(run.run_id)), + #("status", json.string(types.run_status_to_string(run.status))), + #("repo_root", json.string(run.repo_root)), + #("run_path", json.string(run.run_path)), + #("brief_path", json.string(run.brief_path)), + #("report_path", json.string(run.report_path)), + #("provenance_path", json.string(artifact_path(run))), + #("planning_agent", agent_json(run.planning_agent)), + #("execution_agent", agent_json(run.execution_agent)), + #("planning_provenance", json.string(render_planning_provenance( + run.planning_provenance, + ))), + #("notes_source", json.string(render_notes_source(run.notes_source))), + #( + "planning_artifacts", + json.array(planning_artifact_paths(run, events), json.string), + ), + #( + "planner_prompt_path", + json.nullable(from: planner_prompt_path(run.run_path), of: json.string), + ), + #( + "planner_log_path", + json.nullable(from: planner_log_path(run.run_path), of: json.string), + ), + ]), + ), + #( + "confidence_posture", + json.object([ + #( + "level", + json.string(types.confidence_posture_to_string(assessment.posture)), + ), + #("reasons", json.array(assessment.reasons, json.string)), + ]), + ), + #( + "review_state", + json.nullable( + from: review_state_json(run, repo_state_view), + of: identity_json, + ), + ), + #("tasks", json.array(run.tasks, task_json(_, run, events, verification_commands))), + #("event_refs", json.array(events, event_ref_json)), + ]) +} + +fn task_json( + task: types.Task, + run: types.RunRecord, + events: List(types.RunEvent), + verification_commands: List(String), +) -> json.Json { + let relevant_events = + events + |> list.filter(fn(event) { event.task_id == Some(task.id) }) + let verification_log = existing_file(verification_log_path(run.run_path, task.id)) + + json.object([ + #("id", json.string(task.id)), + #("title", json.string(task.title)), + #("state", json.string(types.task_state_to_string(task.state))), + #("summary", json.string(task.summary)), + #("worktree_path", json.string(task.worktree_path)), + #("branch_name", json.string(task.branch_name)), + #("pr_number", json.string(task.pr_number)), + #( + "superseded_pr_numbers", + json.array(task.superseded_pr_numbers, json.int), + ), + #("files_touched", json.array(parse_changed_files(task.summary), json.string)), + #( + "verification", + json.object([ + #("commands", json.array(verification_commands, json.string)), + #( + "outcome", + json.string(verification_outcome(task, relevant_events)), + ), + #( + "log_path", + json.nullable(from: verification_log, of: json.string), + ), + ]), + ), + #( + "artifacts", + json.object([ + #("prompt_paths", json.array(task_prompt_paths(run.run_path, task.id), json.string)), + #("log_paths", json.array(task_log_paths(run.run_path, task.id), json.string)), + #("raw_payload_paths", json.array(raw_payload_paths(run.run_path, task.id), json.string)), + #( + "sanitized_payload_paths", + json.array(sanitized_payload_paths(run.run_path, task.id), json.string), + ), + ]), + ), + #("event_refs", json.array(relevant_events, event_ref_json)), + ]) +} + +fn review_state_json( + run: types.RunRecord, + repo_state_view: Option(repo_state_runtime.RepoStateView), +) -> Option(json.Json) { + case run.repo_state_snapshot { + None -> None + Some(snapshot) -> + Some(json.object([ + #("snapshot_captured_at", json.string(snapshot.captured_at)), + #("captured_open_pr_count", json.int(list.length(snapshot.open_pull_requests))), + #( + "captured_actionable_pr_count", + json.int( + snapshot.open_pull_requests + |> list.filter(fn(pr) { pr.actionable }) + |> list.length, + ), + ), + #( + "drift", + json.string(case repo_state_view { + Some(view) -> repo_state_runtime.drift_label(view.drift) + None -> "unknown" + }), + ), + ])) + } +} + +fn render_task_sections( + run: types.RunRecord, + events: List(types.RunEvent), + verification_commands: List(String), +) -> String { + case run.tasks { + [] -> "- No tasks matched the provenance request." + _ -> + run.tasks + |> list.map(fn(task) { + let relevant_events = + events + |> list.filter(fn(event) { event.task_id == Some(task.id) }) + [ + "- " + <> task.id + <> " (" + <> types.task_state_to_string(task.state) + <> ")", + " Branch: " <> render_empty_as_dash(task.branch_name), + " PR: " <> render_empty_as_dash(task.pr_number), + " Worktree: " <> render_empty_as_dash(task.worktree_path), + " Files touched: " <> render_string_list(parse_changed_files(task.summary)), + " Verification commands: " <> render_string_list(verification_commands), + " Verification outcome: " <> verification_outcome(task, relevant_events), + " Prompt artifacts: " <> render_string_list(task_prompt_paths(run.run_path, task.id)), + " Log artifacts: " <> render_string_list(task_log_paths(run.run_path, task.id)), + " Raw payloads: " <> render_string_list(raw_payload_paths(run.run_path, task.id)), + " Sanitized payloads: " + <> render_string_list(sanitized_payload_paths(run.run_path, task.id)), + " Event refs: " + <> render_string_list( + relevant_events |> list.map(render_event_ref_label), + ), + ] + |> string.join(with: "\n") + }) + |> string.join(with: "\n") + } +} + +fn render_event_refs(events: List(types.RunEvent)) -> String { + case events { + [] -> "- No events recorded yet." + _ -> + events + |> list.map(fn(event) { + "- " + <> render_event_ref_label(event) + <> " " + <> string.replace(in: event.message, each: "\n", with: " ") + }) + |> string.join(with: "\n") + } +} + +fn render_review_state_markdown( + run: types.RunRecord, + repo_state_view: Option(repo_state_runtime.RepoStateView), +) -> String { + case review_state_json(run, repo_state_view) { + Some(_) -> + "## Review State\n" + <> "- Snapshot captured: " + <> case run.repo_state_snapshot { + Some(snapshot) -> snapshot.captured_at + None -> "—" + } + <> "\n- Drift: " + <> case repo_state_view { + Some(view) -> repo_state_runtime.drift_label(view.drift) + None -> "unknown" + } + None -> "" + } +} + +fn filter_tasks( + tasks: List(types.Task), + task_filter: Option(String), +) -> Result(List(types.Task), String) { + case task_filter { + None -> Ok(tasks) + Some(task_id) -> + case list.filter(tasks, fn(task) { task.id == task_id }) { + [] -> Error("No task matched provenance filter `" <> task_id <> "`.") + filtered -> Ok(filtered) + } + } +} + +fn filter_events( + events: List(types.RunEvent), + task_filter: Option(String), +) -> List(types.RunEvent) { + case task_filter { + None -> events + Some(task_id) -> + events + |> list.filter(fn(event) { + event.task_id == Some(task_id) || event.task_id == None + }) + } +} + +fn load_verification_commands(repo_root: String) -> List(String) { + case config.load(project.config_path(repo_root)) { + Ok(loaded_config) -> loaded_config.verification_commands + Error(_) -> [] + } +} + +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.map(fn(event) { event.message }) + |> list.filter_map(extract_path_from_event) + + 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 extract_path_from_event(message: String) -> Result(String, Nil) { + case string.split_once(message, "Planning artifacts: ") { + Ok(#(_, path)) -> Ok(string.trim(path)) + Error(_) -> Error(Nil) + } +} + +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) { + [ + execution_log_path(run_path, task_id), + repair_log_path(run_path, task_id), + payload_repair_log_path(run_path, task_id), + verification_log_path(run_path, task_id), + 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 verification_log_path(run_path: String, task_id: String) -> String { + filepath.join(run_path, "logs/" <> task_id <> ".verify.log") +} + +fn execution_log_path(run_path: String, task_id: String) -> String { + filepath.join(run_path, "logs/" <> task_id <> ".log") +} + +fn repair_log_path(run_path: String, task_id: String) -> String { + filepath.join(run_path, "logs/" <> task_id <> ".repair.log") +} + +fn payload_repair_log_path(run_path: String, task_id: String) -> String { + filepath.join(run_path, "logs/" <> task_id <> ".payload-repair.log") +} + +fn verification_outcome( + task: types.Task, + events: List(types.RunEvent), +) -> String { + case list.any(events, fn(event) { event.kind == "task_verified" }) { + True -> "passed" + False -> + case task.state { + types.Failed -> + case string.contains(does: task.summary, contain: "verification failed") { + True -> "failed" + False -> "not_recorded" + } + _ -> "not_recorded" + } + } +} + +fn parse_changed_files(summary: String) -> List(String) { + case string.split_once(summary, " Changed files: ") { + Ok(#(_, changed_files)) -> + changed_files + |> string.split(",") + |> list.filter_map(fn(entry) { + case string.trim(entry) { + "" -> Error(Nil) + path -> Ok(path) + } + }) + Error(_) -> [] + } +} + +fn event_ref_json(event: types.RunEvent) -> json.Json { + json.object([ + #("event_id", json.string(event_id(event))), + #("kind", json.string(event.kind)), + #("at", json.string(event.at)), + #("task_id", case event.task_id { + Some(task_id) -> json.string(task_id) + None -> json.null() + }), + #("message", json.string(event.message)), + ]) +} + +fn render_event_ref_label(event: types.RunEvent) -> String { + event_id(event) <> "@" <> event.at +} + +fn event_id(event: types.RunEvent) -> String { + case event.task_id { + Some(task_id) -> event.kind <> ":" <> task_id + None -> event.kind <> ":run" + } +} + +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", 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() + }), + ]) +} + +fn render_planning_provenance( + provenance: Option(types.PlanningProvenance), +) -> String { + case provenance { + Some(value) -> types.planning_provenance_label(value) + None -> "(legacy)" + } +} + +fn render_notes_source(notes_source: Option(types.NotesSource)) -> String { + case notes_source { + Some(source) -> types.notes_source_label(source) + None -> "(none)" + } +} + +fn render_string_list(values: List(String)) -> String { + case values { + [] -> "none" + _ -> string.join(values, with: ", ") + } +} + +fn render_optional_path(path: Option(String)) -> String { + case path { + Some(value) -> value + None -> "none" + } +} + +fn render_empty_as_dash(value: String) -> String { + case string.trim(value) { + "" -> "—" + _ -> value + } +} + +fn identity_json(value: json.Json) -> json.Json { + value +} + +fn existing_files(paths: List(String)) -> List(String) { + paths + |> list.filter(file_exists) +} + +fn existing_file(path: String) -> Option(String) { + case file_exists(path) { + True -> Some(path) + False -> None + } +} + +fn file_exists(path: String) -> Bool { + case simplifile.read(path) { + Ok(_) -> True + Error(_) -> False + } +} + +fn file_or_directory_exists(path: String) -> Bool { + file_exists(path) + || case simplifile.read_directory(at: path) { + Ok(_) -> True + Error(_) -> False + } +} + +fn write_file(path: String, contents: String) -> Result(Nil, String) { + case simplifile.write(contents, to: path) { + Ok(Nil) -> Ok(Nil) + Error(error) -> + Error( + "Unable to write " <> path <> ": " <> simplifile.describe_error(error), + ) + } +} diff --git a/src/night_shift/domain/report.gleam b/src/night_shift/domain/report.gleam index 8b0b731..f710c98 100644 --- a/src/night_shift/domain/report.gleam +++ b/src/night_shift/domain/report.gleam @@ -3,6 +3,8 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/string import night_shift/agent_config +import night_shift/domain/confidence +import night_shift/domain/provenance import night_shift/domain/repo_state import night_shift/domain/review_run_projection import night_shift/repo_state_runtime @@ -13,6 +15,7 @@ pub fn render( events: List(types.RunEvent), repo_state_view: Option(repo_state_runtime.RepoStateView), ) -> String { + let confidence_assessment = confidence.assess(run, events, repo_state_view) [ "# Night Shift Report", "", @@ -28,9 +31,14 @@ pub fn render( "- Created at: " <> run.created_at, "- Updated at: " <> run.updated_at, "- Brief: " <> run.brief_path, + "- Provenance: " <> provenance.artifact_path(run), render_repo_state_section(run, repo_state_view), "", "## Summary", + "- Confidence posture: " + <> types.confidence_posture_to_string(confidence_assessment.posture), + "- Confidence reasons: " + <> confidence.reasons_summary(confidence_assessment), render_summary(run.decisions, run.planning_dirty, run.tasks, events), render_planning_validation_summary(events), render_failure_summary(run, events), diff --git a/src/night_shift/infra/run_store.gleam b/src/night_shift/infra/run_store.gleam index 976b464..de10a7e 100644 --- a/src/night_shift/infra/run_store.gleam +++ b/src/night_shift/infra/run_store.gleam @@ -5,6 +5,7 @@ import gleam/result import gleam/string import night_shift/codec/artifact_path import night_shift/codec/journal as journal_codec +import night_shift/domain/provenance import night_shift/domain/repo_state import night_shift/project import night_shift/report @@ -198,7 +199,8 @@ pub fn save( journal_codec.encode_run(run), )) use _ <- result.try(write_events(run.events_path, events)) - write_string(run.report_path, report.render_persisted(run, events)) + use _ <- result.try(write_string(run.report_path, report.render_persisted(run, events))) + provenance.write_persisted(run, events) } pub fn append_event( diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index 2803b47..217f149 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -465,6 +465,47 @@ pub type RunSelector { RunId(String) } +pub type ConfidencePosture { + ConfidenceHigh + ConfidenceGuarded + ConfidenceLow +} + +pub fn confidence_posture_to_string(posture: ConfidencePosture) -> String { + case posture { + ConfidenceHigh -> "high" + ConfidenceGuarded -> "guarded" + ConfidenceLow -> "low" + } +} + +pub type ConfidenceAssessment { + ConfidenceAssessment(posture: ConfidencePosture, reasons: List(String)) +} + +pub type RecoveryClassification { + SafeToResume + ResumeWithWarning + RecoveryManualAttention + RecoveryIrrecoverable +} + +pub fn recovery_classification_to_string( + classification: RecoveryClassification, +) -> String { + case classification { + SafeToResume -> "safe_to_resume" + ResumeWithWarning -> "resume_with_warning" + RecoveryManualAttention -> "manual_attention" + RecoveryIrrecoverable -> "irrecoverable" + } +} + +pub type ProvenanceFormat { + ProvenanceJson + ProvenanceMarkdown +} + /// Repo-local operator configuration for Night Shift. pub type Config { Config( @@ -512,8 +553,14 @@ pub type Command { ) Status(run: RunSelector) Report(run: RunSelector) + Provenance( + run: RunSelector, + task_id: Option(String), + format: ProvenanceFormat, + ) + Doctor(run: RunSelector) Resolve(run: RunSelector) - Resume(run: RunSelector, ui_enabled: Bool) + Resume(run: RunSelector, ui_enabled: Bool, explain_only: Bool) Demo(ui_enabled: Bool) Help } diff --git a/src/night_shift/usecase/doctor.gleam b/src/night_shift/usecase/doctor.gleam new file mode 100644 index 0000000..8842b32 --- /dev/null +++ b/src/night_shift/usecase/doctor.gleam @@ -0,0 +1,351 @@ +import filepath +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import night_shift/git +import night_shift/journal +import night_shift/project +import night_shift/repo_state_runtime +import night_shift/types +import simplifile + +pub fn execute( + repo_root: String, + selector: types.RunSelector, + config: types.Config, +) -> Result(String, String) { + use #(run, events) <- result.try(journal.load(repo_root, selector)) + let repo_state_view = repo_state_runtime.inspect(run, config.branch_prefix).view + let active_lock = active_lock_state(repo_root, run.run_id) + let assessments = + run.tasks |> list.map(diagnose_task(repo_root, run.run_path, _)) + let recommendation = + recommend_next_action(run.status, events, active_lock, assessments) + + Ok(render_doctor(run, repo_state_view, active_lock, recommendation, assessments)) +} + +type ActiveLockState { + ActiveLockMissing + ActiveLockMatched + ActiveLockMismatch(run_id: String) +} + +type TaskAssessment { + TaskAssessment( + task: types.Task, + classification: types.RecoveryClassification, + reasons: List(String), + ) +} + +fn render_doctor( + run: types.RunRecord, + repo_state_view: Option(repo_state_runtime.RepoStateView), + active_lock: ActiveLockState, + recommendation: String, + assessments: List(TaskAssessment), +) -> String { + [ + "# Night Shift Recovery Doctor", + "", + "## Run", + "- Run ID: " <> run.run_id, + "- Status: " <> types.run_status_to_string(run.status), + "- Active lock: " <> active_lock_label(active_lock), + "- Recommendation: " <> recommendation, + case repo_state_view { + Some(view) -> + "- Review drift: " <> repo_state_runtime.drift_label(view.drift) + None -> "" + }, + "", + "## Task Assessments", + render_task_assessments(assessments), + ] + |> list.filter(fn(line) { line != "" }) + |> string.join(with: "\n") +} + +fn render_task_assessments(assessments: List(TaskAssessment)) -> String { + case assessments { + [] -> "- No tasks are recorded for this run." + _ -> + assessments + |> list.map(fn(assessment) { + "- " + <> assessment.task.id + <> " [" + <> types.recovery_classification_to_string(assessment.classification) + <> "] " + <> assessment.task.title + <> "\n " + <> string.join(assessment.reasons, with: "\n ") + }) + |> string.join(with: "\n") + } +} + +fn active_lock_state(repo_root: String, run_id: String) -> ActiveLockState { + case simplifile.read(project.active_lock_path(repo_root)) { + Ok(contents) -> + case string.trim(contents) { + value if value == run_id -> ActiveLockMatched + value -> ActiveLockMismatch(value) + } + Error(_) -> ActiveLockMissing + } +} + +fn active_lock_label(state: ActiveLockState) -> String { + case state { + ActiveLockMatched -> "matched" + ActiveLockMissing -> "missing" + ActiveLockMismatch(run_id) -> "points at " <> run_id + } +} + +fn diagnose_task( + repo_root: String, + run_path: String, + task: types.Task, +) -> TaskAssessment { + let git_log = filepath.join(run_path, "logs/" <> task.id <> ".doctor.git.log") + let execution_log = filepath.join(run_path, "logs/" <> task.id <> ".log") + let worktree_exists = + case task.worktree_path { + "" -> False + path -> directory_exists(path) + } + let mounted_worktree = case task.branch_name { + "" -> Ok(None) + _ -> git.mounted_worktree_path(repo_root, task.branch_name, git_log) + } + + case task.state { + types.Completed -> + TaskAssessment( + task: task, + classification: types.SafeToResume, + reasons: [ + "Task is already completed and does not need recovery work.", + ], + ) + types.Ready | types.Queued -> + TaskAssessment( + task: task, + classification: types.SafeToResume, + reasons: ["Task has not started yet; resume would schedule it normally."], + ) + types.Blocked | types.ManualAttention -> + TaskAssessment( + task: task, + classification: types.RecoveryManualAttention, + reasons: [ + "Task already requires operator attention before Night Shift can continue.", + ], + ) + types.Failed -> + TaskAssessment( + task: task, + classification: types.RecoveryManualAttention, + reasons: [ + "Task is already failed; inspect its report and logs before retrying.", + ], + ) + types.Running -> + diagnose_running_task(task, execution_log, worktree_exists, mounted_worktree) + } +} + +fn diagnose_running_task( + task: types.Task, + execution_log: String, + worktree_exists: Bool, + mounted_worktree: Result(Option(String), String), +) -> TaskAssessment { + case task.worktree_path { + "" -> + TaskAssessment( + task: task, + classification: types.RecoveryIrrecoverable, + reasons: [ + "Task was running, but no worktree path was recorded.", + ], + ) + _ -> + case worktree_exists { + False -> + TaskAssessment( + task: task, + classification: types.RecoveryIrrecoverable, + reasons: [ + "Recorded worktree path no longer exists on disk.", + ], + ) + True -> + case git.has_changes( + task.worktree_path, + filepath.join(task.worktree_path, ".night-shift-doctor.log"), + ) { + True -> + TaskAssessment( + task: task, + classification: types.RecoveryManualAttention, + reasons: [ + "Worktree has uncommitted changes; `resume` would convert this task into manual attention.", + ], + ) + False -> + diagnose_clean_running_task(task, execution_log, mounted_worktree) + } + } + } +} + +fn diagnose_clean_running_task( + task: types.Task, + execution_log: String, + mounted_worktree: Result(Option(String), String), +) -> TaskAssessment { + case mounted_worktree { + Error(message) -> + TaskAssessment( + task: task, + classification: types.ResumeWithWarning, + reasons: [ + "Night Shift could not confirm the mounted worktree for this branch.", + message, + ], + ) + Ok(Some(mounted_path)) -> + case mounted_path == task.worktree_path, file_exists(execution_log) { + False, _ -> + TaskAssessment( + task: task, + classification: types.ResumeWithWarning, + reasons: [ + "Branch is mounted at a different path than the run journal recorded.", + "Recorded path: " <> task.worktree_path, + "Mounted path: " <> mounted_path, + ], + ) + True, False -> + TaskAssessment( + task: task, + classification: types.ResumeWithWarning, + reasons: [ + "Execution log is missing, so recovery evidence is incomplete.", + "Expected log: " <> execution_log, + ], + ) + True, True -> + TaskAssessment( + task: task, + classification: types.SafeToResume, + reasons: [ + "Worktree is mounted, clean, and matches the recorded branch.", + "`resume` should requeue this interrupted task safely.", + ], + ) + } + Ok(None) -> + TaskAssessment( + task: task, + classification: types.ResumeWithWarning, + reasons: [ + "Branch is not mounted in git worktree metadata; Night Shift may need to reattach it during recovery.", + ], + ) + } +} + +fn recommend_next_action( + status: types.RunStatus, + events: List(types.RunEvent), + active_lock: ActiveLockState, + assessments: List(TaskAssessment), +) -> String { + case latest_environment_preflight_failure(events) { + Some(_) -> + "Fix the worktree environment first, then rerun `night-shift start` instead of resuming blindly." + None -> + case status { + types.RunCompleted -> + "This run is already completed; inspect the report and retained worktrees instead of resuming." + _ -> + case has_classification(assessments, types.RecoveryIrrecoverable) { + True -> + "At least one task is irrecoverable from saved state; inspect the journal and replan rather than resuming." + False -> + case has_classification( + assessments, + types.RecoveryManualAttention, + ) { + True -> + "Resolve the manual-attention tasks first; `resume` would not safely clear them." + False -> + case active_lock { + ActiveLockMismatch(other_run_id) -> + "Another run lock is active (" + <> other_run_id + <> "); clear that ambiguity before resuming." + _ -> + case has_classification( + assessments, + types.ResumeWithWarning, + ) { + True -> + "Resume is possible, but review the warnings above before you let Night Shift continue." + False -> + "Resume should be safe from the saved run state." + } + } + } + } + } + } +} + +fn has_classification( + assessments: List(TaskAssessment), + target: types.RecoveryClassification, +) -> Bool { + list.any(assessments, fn(assessment) { + assessment.classification == target + }) +} + +fn latest_environment_preflight_failure( + events: List(types.RunEvent), +) -> Option(String) { + latest_environment_preflight_failure_loop(list.reverse(events)) +} + +fn latest_environment_preflight_failure_loop( + events: List(types.RunEvent), +) -> Option(String) { + case events { + [] -> None + [event, ..rest] -> + case event.kind == "environment_preflight_failed" { + True -> Some(event.message) + False -> latest_environment_preflight_failure_loop(rest) + } + } +} + +fn directory_exists(path: String) -> Bool { + case simplifile.read_directory(at: path) { + Ok(_) -> True + Error(_) -> False + } +} + +fn file_exists(path: String) -> Bool { + case simplifile.read(path) { + Ok(_) -> True + Error(_) -> False + } +} diff --git a/src/night_shift/usecase/plan.gleam b/src/night_shift/usecase/plan.gleam index 746e9c7..76aecfb 100644 --- a/src/night_shift/usecase/plan.gleam +++ b/src/night_shift/usecase/plan.gleam @@ -4,6 +4,7 @@ import gleam/result import night_shift/agent_config import night_shift/domain/repo_state import night_shift/github +import night_shift/journal import night_shift/orchestrator import night_shift/project import night_shift/provider @@ -67,13 +68,22 @@ pub fn execute( True -> orchestrator.replan(seeded_run) False -> orchestrator.plan(seeded_run) }) + use recorded_run <- result.try(journal.append_event( + planned_run, + types.RunEvent( + kind: "planning_artifacts_recorded", + at: system.timestamp(), + message: "Planning artifacts: " <> artifact_path, + task_id: None, + ), + )) Ok(workflow.PlanResult( - run: planned_run, + run: recorded_run, brief_path: target_doc_path, artifact_path: artifact_path, planning_provenance: planning_provenance, warnings: config_warnings(config), - next_action: runs.next_action_for_run(planned_run), + next_action: runs.next_action_for_run(recorded_run), )) } diff --git a/src/night_shift/usecase/provenance.gleam b/src/night_shift/usecase/provenance.gleam new file mode 100644 index 0000000..1e01f50 --- /dev/null +++ b/src/night_shift/usecase/provenance.gleam @@ -0,0 +1,25 @@ +import gleam/result +import gleam/option.{type Option} +import night_shift/domain/provenance as provenance_domain +import night_shift/journal +import night_shift/repo_state_runtime +import night_shift/types + +pub fn execute( + repo_root: String, + selector: types.RunSelector, + task_id: Option(String), + format: types.ProvenanceFormat, + config: types.Config, +) -> Result(String, String) { + use #(run, events) <- result.try(journal.load(repo_root, selector)) + let repo_state_view = repo_state_runtime.inspect(run, config.branch_prefix).view + provenance_domain.render( + run, + events, + repo_state_view, + task_id, + format, + config.verification_commands, + ) +} diff --git a/src/night_shift/usecase/render.gleam b/src/night_shift/usecase/render.gleam index 1e5ebd5..ed73b20 100644 --- a/src/night_shift/usecase/render.gleam +++ b/src/night_shift/usecase/render.gleam @@ -3,6 +3,7 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/string import night_shift/agent_config +import night_shift/domain/confidence import night_shift/domain/decisions as decision_domain import night_shift/domain/review_run_projection import night_shift/repo_state_runtime @@ -60,11 +61,18 @@ pub fn render_status(view: result.StatusResult) -> String { <> render_notes_source(view.run.notes_source) <> render_repo_state_fragment(view.run, view.repo_state_view) <> "\n" + <> "Confidence: " + <> types.confidence_posture_to_string(view.confidence.posture) + <> "\nConfidence reasons: " + <> confidence.reasons_summary(view.confidence) + <> "\n" <> view.summary <> "\nEvents: " <> int.to_string(list.length(view.events)) <> "\nReport: " <> view.run.report_path + <> "\nProvenance: " + <> view.provenance_path } pub fn render_resolve(view: result.ResolveResult) -> String { diff --git a/src/night_shift/usecase/result.gleam b/src/night_shift/usecase/result.gleam index 875c853..7bb5cbc 100644 --- a/src/night_shift/usecase/result.gleam +++ b/src/night_shift/usecase/result.gleam @@ -27,6 +27,8 @@ pub type StatusResult { run: types.RunRecord, events: List(types.RunEvent), repo_state_view: Option(repo_state_runtime.RepoStateView), + confidence: types.ConfidenceAssessment, + provenance_path: String, summary: String, next_action: String, ) diff --git a/src/night_shift/usecase/status.gleam b/src/night_shift/usecase/status.gleam index 2786a50..ab7c82e 100644 --- a/src/night_shift/usecase/status.gleam +++ b/src/night_shift/usecase/status.gleam @@ -1,4 +1,6 @@ import gleam/result +import night_shift/domain/confidence +import night_shift/domain/provenance import night_shift/domain/status import night_shift/repo_state_runtime import night_shift/types @@ -13,10 +15,14 @@ pub fn execute( use #(run, events) <- result.try(runs.load_display_run(repo_root, selector)) let next_action = runs.next_action_for_run(run) let inspection = repo_state_runtime.inspect(run, config.branch_prefix) + let confidence_assessment = + confidence.assess(run, events, inspection.view) Ok(workflow.StatusResult( run: run, events: events, repo_state_view: inspection.view, + confidence: confidence_assessment, + provenance_path: provenance.artifact_path(run), summary: status.summary(run, events, next_action), next_action: next_action, )) diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index e6cd2e9..d009e87 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -85,10 +85,29 @@ pub fn parse_resolve_defaults_to_latest_test() { } pub fn parse_resume_command_with_ui_test() { - let assert Ok(types.Resume(types.RunId("run-123"), True)) = + let assert Ok(types.Resume(types.RunId("run-123"), True, False)) = cli.parse(["resume", "--run", "run-123", "--ui"]) } +pub fn parse_resume_explain_command_test() { + let assert Ok(types.Resume(types.LatestRun, False, True)) = + cli.parse(["resume", "--explain"]) +} + +pub fn parse_doctor_command_test() { + let assert Ok(types.Doctor(types.RunId("run-123"))) = + cli.parse(["doctor", "--run", "run-123"]) +} + +pub fn parse_provenance_command_test() { + let assert Ok(types.Provenance( + types.LatestRun, + Some("task-1"), + types.ProvenanceJson, + )) = + cli.parse(["provenance", "--task", "task-1", "--format", "json"]) +} + pub fn parse_resume_rejects_environment_flag_test() { let assert Error(message) = cli.parse(["resume", "--environment", "dev"]) assert message == "Unsupported flag: --environment" diff --git a/test/trust_surface_test.gleam b/test/trust_surface_test.gleam new file mode 100644 index 0000000..2efd170 --- /dev/null +++ b/test/trust_surface_test.gleam @@ -0,0 +1,243 @@ +import filepath +import gleam/option.{None, Some} +import gleam/string +import night_shift/dashboard +import night_shift/domain/provenance as provenance_domain +import night_shift/domain/repo_state +import night_shift/journal +import night_shift/report +import night_shift/repo_state_runtime +import night_shift/system +import night_shift/types +import night_shift/usecase/doctor +import night_shift_test_support as support +import simplifile + +pub fn persisted_run_writes_provenance_artifact_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-provenance-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + 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(provenance_contents) = + simplifile.read(filepath.join(run.run_path, "provenance.json")) + + assert string.contains(does: provenance_contents, contain: "\"run_id\"") + assert string.contains( + does: provenance_contents, + contain: "\"provenance_path\":\"" + <> filepath.join(run.run_path, "provenance.json") + <> "\"", + ) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn report_includes_confidence_and_provenance_test() { + let rendered = report.render_live(review_run(), [], Some(review_state_view())) + + assert string.contains(does: rendered, contain: "Confidence posture:") + assert string.contains(does: rendered, contain: "Confidence reasons:") + assert string.contains(does: rendered, contain: "Provenance: /tmp/repo/.night-shift/runs/review-run/provenance.json") +} + +pub fn provenance_render_includes_review_drift_test() { + let assert Ok(rendered) = + provenance_domain.render( + review_run(), + [], + Some(review_state_view()), + None, + types.ProvenanceJson, + [], + ) + + assert string.contains(does: rendered, contain: "\"review_state\"") + assert string.contains(does: rendered, contain: "\"drift\":\"yes\"") + assert string.contains( + does: rendered, + contain: "\"superseded_pr_numbers\":[12]", + ) +} + +pub fn dashboard_payload_includes_confidence_and_provenance_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-dashboard-trust-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + 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(updated_run) = + journal.append_event( + run, + types.RunEvent( + kind: "execution_payload_warning", + at: system.timestamp(), + message: "Accepted a recovered execution payload.", + task_id: Some("demo-task"), + ), + ) + let assert Ok(run_payload) = dashboard.run_json(repo_root, updated_run.run_id) + + assert string.contains(does: run_payload, contain: "\"confidence_posture\"") + assert string.contains(does: run_payload, contain: "\"provenance_path\"") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn doctor_flags_dirty_and_missing_worktrees_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-doctor-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + let missing_worktree = filepath.join(base_dir, "missing-worktree") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let assert Ok(_) = + simplifile.write("dirty\n", to: filepath.join(repo_root, "DIRTY.md")) + let updated_run = + types.RunRecord( + ..run, + tasks: [ + types.Task( + id: "dirty-task", + title: "Dirty task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: repo_root, + branch_name: "night-shift/dirty-task", + pr_number: "", + summary: "", + ), + types.Task( + id: "missing-task", + title: "Missing task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: missing_worktree, + branch_name: "night-shift/missing-task", + pr_number: "", + summary: "", + ), + ], + ) + let assert Ok(_) = journal.rewrite_run(updated_run) + let assert Ok(rendered) = + doctor.execute(repo_root, types.LatestRun, types.default_config()) + + assert string.contains(does: rendered, contain: "[manual_attention] Dirty task") + assert string.contains(does: rendered, contain: "[irrecoverable] Missing task") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +fn review_run() -> types.RunRecord { + types.RunRecord( + run_id: "review-run", + repo_root: "/tmp/repo", + run_path: "/tmp/repo/.night-shift/runs/review-run", + brief_path: "/tmp/repo/.night-shift/runs/review-run/brief.md", + state_path: "/tmp/repo/.night-shift/runs/review-run/state.json", + events_path: "/tmp/repo/.night-shift/runs/review-run/events.jsonl", + report_path: "/tmp/repo/.night-shift/runs/review-run/report.md", + lock_path: "/tmp/repo/.night-shift/active.lock", + planning_agent: types.resolved_agent_from_provider(types.Codex), + execution_agent: types.resolved_agent_from_provider(types.Codex), + environment_name: "default", + max_workers: 1, + notes_source: None, + planning_provenance: Some(types.ReviewsOnly), + repo_state_snapshot: Some(repo_state_snapshot()), + decisions: [], + planning_dirty: False, + status: types.RunCompleted, + created_at: "2026-04-13T17:30:00Z", + updated_at: "2026-04-13T18:02:00Z", + tasks: [ + types.Task( + id: "rewrite-root", + title: "rewrite-root", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [12], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Completed, + worktree_path: "/tmp/repo/.night-shift/runs/review-run/worktrees/rewrite-root", + branch_name: "night-shift/rewrite-root", + pr_number: "15", + summary: "Updated rewrite-root", + ), + ], + ) +} + +fn repo_state_snapshot() -> repo_state.RepoStateSnapshot { + repo_state.RepoStateSnapshot( + captured_at: "2026-04-13T17:30:00Z", + digest: "digest", + open_pull_requests: [ + repo_state.RepoPullRequestSnapshot( + number: 12, + title: "Root rewrite", + url: "https://example.test/pr/12", + head_ref_name: "night-shift/root", + base_ref_name: "main", + review_decision: "REVIEW_REQUIRED", + failing_checks: [], + review_comments: ["Please rewrite the root document."], + actionable: True, + impacted: True, + ), + ], + ) +} + +fn review_state_view() -> repo_state_runtime.RepoStateView { + repo_state_runtime.RepoStateView( + snapshot_captured_at: "2026-04-13T17:30:00Z", + open_pr_count: 2, + actionable_pr_count: 1, + drift: repo_state_runtime.RepoStateDrifted, + ) +} From 2962e4c514c8a13be0acae4800b72972e33cf7d3 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:16:53 -0700 Subject: [PATCH 02/13] Add configurable PR reviewer handoff metadata --- .codex/skills/qa-night-shift/SKILL.md | 18 + docs/configuration.md | 41 +- docs/providers-and-delivery.md | 27 +- docs/state-and-artifacts.md | 14 +- src/night_shift/codec/journal.gleam | 66 ++++ src/night_shift/config.gleam | 213 +++++++++++ src/night_shift/domain/pr_handoff.gleam | 358 ++++++++++++++++++ src/night_shift/domain/pull_request.gleam | 11 +- src/night_shift/domain/repo_state.gleam | 4 + src/night_shift/github.gleam | 191 +++++++++- src/night_shift/infra/run_store.gleam | 1 + src/night_shift/infra/task_delivery.gleam | 222 ++++++++++- .../orchestrator/execution_phase.gleam | 37 +- src/night_shift/types.gleam | 122 ++++++ test/domain_pr_handoff_test.gleam | 147 +++++++ test/domain_pull_request_test.gleam | 1 + test/domain_report_test.gleam | 1 + test/night_shift_cli_config_test.gleam | 55 +++ .../night_shift_execution_delivery_test.gleam | 129 +++++++ ...ight_shift_persistence_provider_test.gleam | 11 + test/night_shift_test_support.gleam | 89 +++++ 21 files changed, 1744 insertions(+), 14 deletions(-) create mode 100644 src/night_shift/domain/pr_handoff.gleam create mode 100644 test/domain_pr_handoff_test.gleam diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 5eebb72..b65823a 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -168,6 +168,24 @@ In review-driven runs, pay attention to repo-state evidence: - whether `status` and `report` show payload-repair attempts, successes, and failures with usable artifact paths +In delivery-focused investigations, also validate reviewer handoff behavior +when the repo config uses `[handoff]`: + +- whether the delivered PR body includes or omits the Night Shift-owned + handoff overlay according to `pr_body_mode` +- whether Night Shift preserves manual PR text outside its marked body region + across later updates +- whether configured snippet files are spliced into the PR body or managed + comment in the expected order +- whether unreadable snippet paths degrade to `pr_handoff_warning` evidence + instead of blocking PR delivery +- whether managed comments stay disabled by default and only appear when + `[handoff].managed_comment = true` +- whether the managed comment is updated in place instead of adding new comment + noise on each delivery +- whether handoff provenance labels clearly separate deterministic Night + Shift-owned evidence from provider-authored summary text + Use small tasks that validate the requested behavior instead of inviting large feature work. diff --git a/docs/configuration.md b/docs/configuration.md index f26cbd5..093f47d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -description: Configure profiles, phase defaults, verification commands, and provider overrides. +description: Configure profiles, phase defaults, verification commands, handoff behavior, and provider overrides. permalink: /configuration/ --- @@ -50,6 +50,13 @@ mode = "ask" [verification] commands = ["gleam test"] + +[handoff] +enabled = true +pr_body_mode = "append" +managed_comment = false +provenance = "structured" +pr_body_prefix_path = ".night-shift/pr-handoff-prefix.md" ``` If `config.toml` is empty, Night Shift still works. The built-in default @@ -117,6 +124,38 @@ These top-level settings shape how Night Shift delivers completed work: - `notifiers`: currently `console` and `report_file` - `[verification].commands`: commands to run locally before PR delivery +## Handoff Settings + +`[handoff]` controls the optional reviewer-facing metadata that Night Shift can +overlay onto delivered pull requests. + +Supported fields: + +- `enabled`: master switch for Night Shift handoff output +- `pr_body_mode`: `off`, `append`, or `prepend` +- `managed_comment`: whether Night Shift owns and updates one incremental PR + comment with "Since Last Review" deltas +- `provenance`: `minimal`, `light`, or `structured` +- `include_files_touched` +- `include_acceptance` +- `include_stack_context` +- `include_verification_summary` +- `pr_body_prefix_path`, `pr_body_suffix_path` +- `comment_prefix_path`, `comment_suffix_path` + +When `[handoff]` is absent, Night Shift uses the conservative default: + +- handoff enabled +- PR body overlay appended +- managed comment disabled +- structured provenance +- files touched, stack context, and verification summary included + +Snippet paths are repo-relative markdown fragments. Night Shift splices them +around its generated handoff sections; they augment the structured layout and +do not replace it. If a configured snippet path cannot be read, Night Shift +falls back to generated content and records a warning event. + Example configs live in: - `examples/config-single-profile.toml` diff --git a/docs/providers-and-delivery.md b/docs/providers-and-delivery.md index 564479a..202e7ae 100644 --- a/docs/providers-and-delivery.md +++ b/docs/providers-and-delivery.md @@ -64,6 +64,8 @@ Night Shift's current delivery model is: - each completed task is delivered as a pull request - dependent tasks may be delivered as stacked pull requests - verification runs locally before PR creation +- Night Shift can overlay a configurable reviewer handoff block onto the PR + body, with repo-local markdown snippets before or after the generated block - the local markdown report is updated throughout the run - `night-shift report` is the live audit view for review-driven runs and can show current drift against the saved open-PR snapshot @@ -82,7 +84,30 @@ Night Shift's current delivery model is: worktree before falling back to manual attention Delivery behavior is shaped by `base_branch`, `branch_prefix`, -`pr_title_prefix`, and `[verification].commands` in `config.toml`. +`pr_title_prefix`, `[verification].commands`, and `[handoff]` in +`config.toml`. + +## Reviewer Handoff + +When handoff output is enabled, Night Shift can add a structured PR-body region +covering: + +- context for why the PR exists +- scope such as `files_touched`, acceptance cues, and stack/supersession + metadata when configured +- model-authored summary text and known risks +- deterministic evidence such as verification output +- provenance labels that distinguish Night Shift-owned facts from inferred + provider-authored text + +Night Shift encloses its PR-body overlay in stable markers and only rewrites +that marked region on later updates, so manual text outside the markers can +survive future delivery passes. + +If `[handoff].managed_comment = true`, Night Shift also owns one PR comment for +incremental review deltas such as "Since Last Review", review-driven context, +and replacement-stack status. Repositories with stricter comment etiquette can +leave that disabled and still use the PR-body overlay. ## Dashboard diff --git a/docs/state-and-artifacts.md b/docs/state-and-artifacts.md index 6418fee..c7c8a65 100644 --- a/docs/state-and-artifacts.md +++ b/docs/state-and-artifacts.md @@ -48,6 +48,9 @@ The run record itself stores: - planning provenance such as `notes only` or `reviews + notes` - an open-PR repo-state snapshot for review-driven plans - mechanically derived supersession lineage on replacement tasks +- persisted PR handoff state per delivered task, including the last delivered + commit SHA, verification digest, files list, and whether Night Shift had + emitted a body overlay or managed comment - recorded decisions - `planning_dirty` - task list and task states @@ -76,6 +79,8 @@ vanishing into the terminal scrollback. - worktree retention and pruning notes - execution recovery warnings when Night Shift accepted a sanitized or recovered provider payload +- PR handoff warnings such as unreadable snippet paths or managed-comment + update failures - payload-repair attempt, success, and failure notes when Night Shift retried a malformed execution result in place - task summaries @@ -89,7 +94,8 @@ tree when a stored snapshot exists, so its live output is authoritative for current drift while `report.md` remains durable and offline-readable. Task-level provider logs and prompt files live under each run's `logs/` -directory. +directory. PR delivery also keeps the rendered pull request body under `logs/` +so operators can inspect the exact handoff Night Shift attempted to publish. Task worktrees are intentionally sticky. Night Shift keeps them mounted after completion so operators can inspect delivery state or resume later without @@ -115,6 +121,12 @@ under distinct `.payload-repair.*` log and prompt artifacts. If that retry still fails, manual-attention summaries include both the original malformed payload path and the repair artifacts. +When `[handoff]` points at snippet files such as `pr_body_prefix_path` or +`comment_suffix_path`, Night Shift reads those repo-relative markdown files at +delivery time. Missing or unreadable snippets do not block PR delivery; Night +Shift records a `pr_handoff_warning` event and falls back to generated handoff +content. + ## Active Lock Night Shift keeps `./.night-shift/active.lock` so only one active run can diff --git a/src/night_shift/codec/journal.gleam b/src/night_shift/codec/journal.gleam index ce5bdb1..109baa8 100644 --- a/src/night_shift/codec/journal.gleam +++ b/src/night_shift/codec/journal.gleam @@ -44,6 +44,7 @@ pub fn encode_run(run: types.RunRecord) -> String { #("created_at", json.string(run.created_at)), #("updated_at", json.string(run.updated_at)), #("tasks", json.array(run.tasks, encode_task)), + #("handoff_states", json.array(run.handoff_states, encode_task_handoff_state)), ]) |> json.to_string } @@ -193,6 +194,23 @@ fn encode_task(task: types.Task) -> json.Json { ]) } +fn encode_task_handoff_state(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_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 encode_decision_request(request: types.DecisionRequest) -> json.Json { json.object([ #("key", json.string(request.key)), @@ -262,6 +280,11 @@ fn run_decoder() -> decode.Decoder(types.RunRecord) { use created_at <- decode.field("created_at", decode.string) use updated_at <- decode.field("updated_at", decode.string) use tasks <- decode.field("tasks", decode.list(task_decoder())) + use handoff_states <- decode.optional_field( + "handoff_states", + None, + decode.optional(decode.list(task_handoff_state_decoder())), + ) decode.success(types.RunRecord( run_id: run_id, repo_root: repo_root, @@ -300,6 +323,10 @@ fn run_decoder() -> decode.Decoder(types.RunRecord) { created_at: created_at, updated_at: updated_at, tasks: tasks, + handoff_states: case handoff_states { + Some(entries) -> entries + None -> [] + }, )) } @@ -341,6 +368,7 @@ fn legacy_run_decoder() -> decode.Decoder(types.RunRecord) { created_at: created_at, updated_at: updated_at, tasks: tasks, + handoff_states: [], )) } @@ -432,6 +460,44 @@ fn task_decoder() -> decode.Decoder(types.Task) { )) } +fn task_handoff_state_decoder() -> decode.Decoder(types.TaskHandoffState) { + use task_id <- decode.field("task_id", decode.string) + use delivered_pr_number <- decode.field("delivered_pr_number", decode.string) + use last_delivered_commit_sha <- decode.field( + "last_delivered_commit_sha", + decode.string, + ) + use last_handoff_files <- decode.field( + "last_handoff_files", + decode.list(decode.string), + ) + use last_verification_digest <- decode.field( + "last_verification_digest", + decode.string, + ) + use last_risks <- decode.field("last_risks", decode.list(decode.string)) + use last_handoff_updated_at <- decode.field( + "last_handoff_updated_at", + decode.string, + ) + use body_region_present <- decode.field("body_region_present", decode.bool) + use managed_comment_present <- decode.field( + "managed_comment_present", + decode.bool, + ) + decode.success(types.TaskHandoffState( + task_id: task_id, + delivered_pr_number: delivered_pr_number, + last_delivered_commit_sha: last_delivered_commit_sha, + last_handoff_files: last_handoff_files, + last_verification_digest: last_verification_digest, + last_risks: last_risks, + last_handoff_updated_at: last_handoff_updated_at, + body_region_present: body_region_present, + managed_comment_present: managed_comment_present, + )) +} + fn decision_request_decoder() -> decode.Decoder(types.DecisionRequest) { use key <- decode.field("key", decode.string) use question <- decode.field("question", decode.string) diff --git a/src/night_shift/config.gleam b/src/night_shift/config.gleam index a427f3b..6a45761 100644 --- a/src/night_shift/config.gleam +++ b/src/night_shift/config.gleam @@ -15,6 +15,7 @@ import simplifile type Section { RootSection VerificationSection + HandoffSection ProfileSection(name: String) ProfileOverridesSection(name: String) } @@ -61,10 +62,17 @@ pub fn render(config: types.Config) -> String { <> shared.render_string_list(config.verification_commands) } + let handoff_lines = + case config.handoff == types.default_handoff_config() { + True -> "" + False -> render_handoff(config.handoff) + } + string.join(root_lines, with: "\n") <> "\n\n" <> profile_lines <> verification_lines + <> handoff_lines <> "\n" } @@ -124,6 +132,7 @@ fn parse_section(section: String) -> Result(Section, String) { case inner { "verification" -> Ok(VerificationSection) + "handoff" -> Ok(HandoffSection) _ -> case string.split(inner, ".") { ["profiles", name] -> Ok(ProfileSection(name)) @@ -220,6 +229,131 @@ fn apply_value( state.section, )) + HandoffSection, "enabled" -> { + use value <- result.try(parse_bool(raw_value, "handoff")) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, enabled: value), + ), + state.section, + )) + } + + HandoffSection, "pr_body_mode" -> { + use mode <- result.try( + shared.parse_string(raw_value) + |> types.handoff_body_mode_from_string, + ) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, pr_body_mode: mode), + ), + state.section, + )) + } + + HandoffSection, "managed_comment" -> { + use value <- result.try(parse_bool(raw_value, "handoff")) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig( + ..config.handoff, + managed_comment: value, + ), + ), + state.section, + )) + } + + HandoffSection, "provenance" -> { + use level <- result.try( + shared.parse_string(raw_value) + |> types.handoff_provenance_from_string, + ) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, provenance: level), + ), + state.section, + )) + } + + HandoffSection, "include_files_touched" -> + parse_handoff_bool_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, include_files_touched: value) + }, + ) + + HandoffSection, "include_acceptance" -> + parse_handoff_bool_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, include_acceptance: value) + }, + ) + + HandoffSection, "include_stack_context" -> + parse_handoff_bool_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, include_stack_context: value) + }, + ) + + HandoffSection, "include_verification_summary" -> + parse_handoff_bool_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, include_verification_summary: value) + }, + ) + + HandoffSection, "pr_body_prefix_path" -> + parse_handoff_path_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, pr_body_prefix_path: value) + }, + ) + + HandoffSection, "pr_body_suffix_path" -> + parse_handoff_path_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, pr_body_suffix_path: value) + }, + ) + + HandoffSection, "comment_prefix_path" -> + parse_handoff_path_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, comment_prefix_path: value) + }, + ) + + HandoffSection, "comment_suffix_path" -> + parse_handoff_path_field( + state, + raw_value, + fn(handoff, value) { + types.HandoffConfig(..handoff, comment_suffix_path: value) + }, + ) + ProfileSection(profile_name), "provider" -> { use provider <- result.try( shared.parse_string(raw_value) @@ -305,6 +439,43 @@ fn blank_profile(name: String) -> types.AgentProfile { types.AgentProfile(..types.default_agent_profile(), name: name) } +fn parse_bool(raw_value: String, context: String) -> Result(Bool, String) { + case shared.parse_string(raw_value) { + "true" -> Ok(True) + "false" -> Ok(False) + _ -> Error("Invalid boolean in " <> context <> ": " <> raw_value) + } +} + +fn parse_handoff_bool_field( + state: ParseState, + raw_value: String, + update: fn(types.HandoffConfig, Bool) -> types.HandoffConfig, +) -> Result(ParseState, String) { + use value <- result.try(parse_bool(raw_value, "handoff")) + Ok(ParseState( + types.Config(..state.config, handoff: update(state.config.handoff, value)), + state.section, + )) +} + +fn parse_handoff_path_field( + state: ParseState, + raw_value: String, + update: fn( + types.HandoffConfig, + Option(String), + ) -> types.HandoffConfig, +) -> Result(ParseState, String) { + Ok(ParseState( + types.Config( + ..state.config, + handoff: update(state.config.handoff, shared.parse_optional_string(raw_value)), + ), + state.section, + )) +} + fn upsert_provider_override( overrides: List(types.ProviderOverride), key: String, @@ -392,3 +563,45 @@ fn render_profile(profile: types.AgentProfile) -> String { with: "\n", ) } + +fn render_handoff(handoff: types.HandoffConfig) -> String { + let lines = [ + "", + "[handoff]", + "enabled = " <> render_bool(handoff.enabled), + "pr_body_mode = " + <> shared.render_string( + types.handoff_body_mode_to_string(handoff.pr_body_mode), + ), + "managed_comment = " <> render_bool(handoff.managed_comment), + "provenance = " + <> shared.render_string( + types.handoff_provenance_to_string(handoff.provenance), + ), + "include_files_touched = " <> render_bool(handoff.include_files_touched), + "include_acceptance = " <> render_bool(handoff.include_acceptance), + "include_stack_context = " <> render_bool(handoff.include_stack_context), + "include_verification_summary = " + <> render_bool(handoff.include_verification_summary), + ] + |> list.append(optional_handoff_path("pr_body_prefix_path", handoff.pr_body_prefix_path)) + |> list.append(optional_handoff_path("pr_body_suffix_path", handoff.pr_body_suffix_path)) + |> list.append(optional_handoff_path("comment_prefix_path", handoff.comment_prefix_path)) + |> list.append(optional_handoff_path("comment_suffix_path", handoff.comment_suffix_path)) + + "\n" <> string.join(lines, with: "\n") +} + +fn optional_handoff_path(key: String, path: Option(String)) -> List(String) { + case path { + Some(value) -> [key <> " = " <> shared.render_string(value)] + None -> [] + } +} + +fn render_bool(value: Bool) -> String { + case value { + True -> "true" + False -> "false" + } +} diff --git a/src/night_shift/domain/pr_handoff.gleam b/src/night_shift/domain/pr_handoff.gleam new file mode 100644 index 0000000..3288719 --- /dev/null +++ b/src/night_shift/domain/pr_handoff.gleam @@ -0,0 +1,358 @@ +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import night_shift/domain/repo_state +import night_shift/types + +pub const body_start_marker = "" +pub const body_end_marker = "" + +pub type Snippets { + Snippets( + body_prefix: Option(String), + body_suffix: Option(String), + comment_prefix: Option(String), + comment_suffix: Option(String), + ) +} + +pub type RepoStateStatus { + RepoStateStatus(drift: String, open_pr_count: Int, actionable_pr_count: Int) +} + +pub fn empty_snippets() -> Snippets { + Snippets( + body_prefix: None, + body_suffix: None, + comment_prefix: None, + comment_suffix: None, + ) +} + +pub fn verification_digest(output: String) -> String { + repo_state.text_digest(output) +} + +pub fn body_region_enabled(handoff: types.HandoffConfig) -> Bool { + handoff.enabled && handoff.pr_body_mode != types.HandoffBodyOff +} + +pub fn wrap_body_region(content: String) -> String { + body_start_marker <> "\n" <> content <> "\n" <> body_end_marker +} + +pub fn comment_marker(task_id: String) -> String { + "" +} + +pub fn render_body_region( + handoff: types.HandoffConfig, + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, + snippets: Snippets, +) -> String { + let sections = + [ + render_optional(snippets.body_prefix), + "## Context\n" <> render_context(run, task), + render_scope(handoff, task, execution_result), + "## Summary\n" <> fallback_text(execution_result.pr.summary), + render_evidence(handoff, execution_result, verification_output), + "## Known Risks\n" <> bullet_list(execution_result.pr.risks), + render_provenance(handoff.provenance, run, task, execution_result, verification_output), + render_optional(snippets.body_suffix), + ] + |> list.filter(fn(section) { string.trim(section) != "" }) + |> string.join(with: "\n\n") + + wrap_body_region(sections) +} + +pub fn render_managed_comment( + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, + previous_state: Option(types.TaskHandoffState), + repo_state_status: Option(RepoStateStatus), + snippets: Snippets, +) -> String { + [ + render_optional(snippets.comment_prefix), + "## Since Last Review\n" + <> render_delta(previous_state, execution_result, verification_output), + "## Review Feedback Status\n" <> render_review_feedback_status(run), + render_stack_status(task, repo_state_status), + render_optional(snippets.comment_suffix), + comment_marker(task.id), + ] + |> list.filter(fn(section) { string.trim(section) != "" }) + |> string.join(with: "\n\n") +} + +fn render_context(run: types.RunRecord, task: types.Task) -> String { + let origin = case run.planning_provenance { + Some(provenance) -> + case types.planning_provenance_uses_reviews(provenance) { + True -> "Review-driven replacement from open PR feedback." + False -> "Planned from the Night Shift brief." + } + None -> "Planned from the Night Shift brief." + } + + bullet_list([ + "Reason: " <> origin, + "Run: " <> run.run_id, + "Task: " <> task.id, + "Brief: " <> run.brief_path, + ]) +} + +fn render_scope( + handoff: types.HandoffConfig, + task: types.Task, + execution_result: types.ExecutionResult, +) -> String { + let scope_lines = [] + let scope_lines = case handoff.include_files_touched { + True -> list.append(scope_lines, ["Files touched: " <> inline_list(execution_result.files_touched)]) + False -> scope_lines + } + let scope_lines = case handoff.include_acceptance { + True -> list.append(scope_lines, ["Acceptance: " <> inline_list(task.acceptance)]) + False -> scope_lines + } + let scope_lines = case handoff.include_stack_context { + True -> + list.append(scope_lines, [ + "Branch: " <> fallback_scalar(task.branch_name), + "PR: " <> fallback_scalar(task.pr_number), + "Supersedes: " <> render_pr_numbers(task.superseded_pr_numbers), + ]) + False -> scope_lines + } + + "## Scope\n" <> bullet_list(scope_lines) +} + +fn render_evidence( + handoff: types.HandoffConfig, + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + let sections = ["Demo evidence:\n" <> bullet_list(execution_result.demo_evidence)] + let sections = case handoff.include_verification_summary { + True -> list.append(sections, [ + "Verification digest: " + <> verification_digest(verification_output) + <> "\n\n```text\n" + <> verification_output + <> "\n```", + ]) + False -> sections + } + + "## Evidence\n" <> string.join(sections, with: "\n\n") +} + +fn render_provenance( + level: types.HandoffProvenance, + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + let base_lines = [ + "Planning provenance: " <> planning_label(run.planning_provenance), + "Execution summary source: model-authored", + ] + + let lines = case level { + types.HandoffProvenanceMinimal -> base_lines + types.HandoffProvenanceLight -> + list.append(base_lines, [ + "Execution provider: " <> agent_summary(run.execution_agent), + ]) + types.HandoffProvenanceStructured -> + list.append(base_lines, [ + "Planning agent: " <> agent_summary(run.planning_agent), + "Execution agent: " <> agent_summary(run.execution_agent), + "Deterministic evidence: run id, task id, files touched, verification output, superseded lineage", + "Inferred/model-authored: PR summary, risks, demo narrative", + "Verification digest: " <> verification_digest(verification_output), + "Task status: " <> types.task_state_to_string(execution_result.status), + "Task ref: " <> task.id, + ]) + } + + "## Provenance\n" <> bullet_list(lines) +} + +fn render_delta( + previous_state: Option(types.TaskHandoffState), + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + let current_files = execution_result.files_touched + let current_risks = execution_result.pr.risks + let current_digest = verification_digest(verification_output) + + case previous_state { + None -> + bullet_list([ + "Initial Night Shift handoff for this PR.", + "Files in this delivery: " <> inline_list(current_files), + "Verification changed: baseline", + "Known risks: " <> inline_list(current_risks), + ]) + Some(state) -> + bullet_list([ + "Added files: " + <> inline_list(list_difference(current_files, state.last_handoff_files)), + "Removed files: " + <> inline_list(list_difference(state.last_handoff_files, current_files)), + "Verification changed: " <> bool_label(state.last_verification_digest != current_digest), + "Risks changed: " <> bool_label(string.join(state.last_risks, with: "\n") != string.join(current_risks, with: "\n")), + ]) + } +} + +fn render_review_feedback_status(run: types.RunRecord) -> String { + case run.planning_provenance { + Some(provenance) -> + case types.planning_provenance_uses_reviews(provenance), run.repo_state_snapshot { + True, Some(snapshot) -> { + let actionable_lines = + snapshot.open_pull_requests + |> list.filter(fn(pr) { pr.actionable }) + |> list.flat_map(fn(pr) { + let comments = + pr.review_comments + |> list.map(fn(comment) { + "#" <> int.to_string(pr.number) <> ": " <> comment + }) + let checks = + pr.failing_checks + |> list.map(fn(check) { + "#" <> int.to_string(pr.number) <> " check: " <> check + }) + list.append(comments, checks) + }) + + case actionable_lines { + [] -> "- Review-driven run, but no actionable comments or failing checks were captured." + _ -> bullet_list(actionable_lines) + } + } + _, _ -> "- No ingested review feedback for this update." + } + None -> "- No ingested review feedback for this update." + } +} + +fn render_stack_status( + task: types.Task, + repo_state_status: Option(RepoStateStatus), +) -> String { + let lines = case task.superseded_pr_numbers { + [] -> [] + pr_numbers -> ["Supersedes: " <> render_pr_numbers(pr_numbers)] + } + let lines = case repo_state_status { + Some(status) -> + list.append(lines, [ + "Repo-state drift: " <> status.drift, + "Open PRs: " <> int.to_string(status.open_pr_count), + "Actionable PRs: " <> int.to_string(status.actionable_pr_count), + ]) + None -> lines + } + + "## Stack / Replacement Status\n" <> bullet_list(lines) +} + +fn planning_label(provenance: Option(types.PlanningProvenance)) -> String { + case provenance { + Some(value) -> types.planning_provenance_label(value) + None -> "unknown" + } +} + +fn agent_summary(agent: types.ResolvedAgentConfig) -> String { + let model_fragment = case agent.model { + Some(model) -> " model=" <> model + None -> "" + } + let reasoning_fragment = case agent.reasoning { + Some(reasoning) -> " reasoning=" <> types.reasoning_to_string(reasoning) + None -> "" + } + types.provider_to_string(agent.provider) <> model_fragment <> reasoning_fragment +} + +fn bullet_list(items: List(String)) -> String { + case items + |> list.filter(fn(item) { string.trim(item) != "" }) + { + [] -> "- None" + filtered -> + filtered + |> list.map(fn(item) { "- " <> item }) + |> string.join(with: "\n") + } +} + +fn fallback_text(value: String) -> String { + case string.trim(value) { + "" -> "- None" + trimmed -> trimmed + } +} + +fn render_pr_numbers(pr_numbers: List(Int)) -> String { + case pr_numbers { + [] -> "(none)" + _ -> + pr_numbers + |> list.map(fn(number) { "#" <> int.to_string(number) }) + |> string.join(with: ", ") + } +} + +fn inline_list(items: List(String)) -> String { + case items + |> list.filter(fn(item) { string.trim(item) != "" }) + { + [] -> "(none)" + filtered -> string.join(filtered, with: ", ") + } +} + +fn list_difference(left: List(String), right: List(String)) -> List(String) { + left + |> list.filter(fn(item) { !list.contains(right, item) }) +} + +fn bool_label(value: Bool) -> String { + case value { + True -> "yes" + False -> "no" + } +} + +fn fallback_scalar(value: String) -> String { + case string.trim(value) { + "" -> "(none)" + trimmed -> trimmed + } +} + +fn render_optional(value: Option(String)) -> String { + case value { + Some(contents) -> string.trim(contents) + None -> "" + } +} diff --git a/src/night_shift/domain/pull_request.gleam b/src/night_shift/domain/pull_request.gleam index 356e458..1a751b8 100644 --- a/src/night_shift/domain/pull_request.gleam +++ b/src/night_shift/domain/pull_request.gleam @@ -3,7 +3,7 @@ import gleam/list import gleam/string import night_shift/types -pub fn render_body( +pub fn render_legacy_body( run: types.RunRecord, task: types.Task, execution_result: types.ExecutionResult, @@ -27,6 +27,15 @@ pub fn render_body( <> " -->" } +pub fn render_body( + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + render_legacy_body(run, task, execution_result, verification_output) +} + pub fn review_task( number: Int, url: String, diff --git a/src/night_shift/domain/repo_state.gleam b/src/night_shift/domain/repo_state.gleam index c1a1386..cefe10f 100644 --- a/src/night_shift/domain/repo_state.gleam +++ b/src/night_shift/domain/repo_state.gleam @@ -67,6 +67,10 @@ pub fn drifted(stored: RepoStateSnapshot, live: RepoStateSnapshot) -> Bool { stored.digest != live.digest } +pub fn text_digest(value: String) -> String { + sha256_hex(value) +} + fn actionable_head_refs( open_pull_requests: List(RepoPullRequestSnapshot), ) -> List(String) { diff --git a/src/night_shift/github.gleam b/src/night_shift/github.gleam index 89d4dca..aa1a8c1 100644 --- a/src/night_shift/github.gleam +++ b/src/night_shift/github.gleam @@ -5,11 +5,14 @@ import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string +import night_shift/domain/pr_handoff import night_shift/domain/repo_state import night_shift/shell import night_shift/system +import night_shift/types import simplifile /// Minimal pull request identity returned after delivery. @@ -17,6 +20,15 @@ pub type PullRequest { PullRequest(number: Int, url: String, head_ref_name: String, title: String) } +pub type CommentUpsert { + CommentCreated + CommentUpdated +} + +type IssueComment { + IssueComment(id: Int, body: String) +} + /// Review details ingested when Night Shift turns open PR feedback into work. pub type ReviewWorkItem { ReviewWorkItem( @@ -38,7 +50,9 @@ pub fn open_or_update_pr( branch_name: String, base_ref: String, title: String, - body: String, + legacy_body: String, + handoff_region: Option(String), + handoff: types.HandoffConfig, run_path: String, log_path: String, ) -> Result(PullRequest, String) { @@ -48,10 +62,19 @@ pub fn open_or_update_pr( |> string.replace(each: ":", with: "-") let body_path = filepath.join(run_path, "logs/" <> safe_branch_name <> ".pr.md") - use _ <- result.try(write_file(body_path, body)) case find_pull_request(cwd, branch_name, log_path) { Ok(pull_request) -> { + let final_body = + existing_pr_body(cwd, pull_request.number, log_path) + |> result.map(fn(existing_body) { + compose_pr_body(existing_body, legacy_body, handoff_region, handoff) + }) + let final_body = case final_body { + Ok(body) -> body + Error(_) -> compose_pr_body("", legacy_body, handoff_region, handoff) + } + use _ <- result.try(write_file(body_path, final_body)) use _ <- result.try(edit_pull_request( cwd, pull_request.number, @@ -62,6 +85,8 @@ pub fn open_or_update_pr( Ok(PullRequest(..pull_request, title: title)) } Error(_) -> { + let final_body = compose_pr_body("", legacy_body, handoff_region, handoff) + use _ <- result.try(write_file(body_path, final_body)) use create_output <- result.try(create_pull_request( cwd, branch_name, @@ -79,6 +104,30 @@ pub fn open_or_update_pr( } } +pub fn upsert_handoff_comment( + cwd: String, + pr_number: Int, + task_id: String, + body: String, + log_path: String, +) -> Result(CommentUpsert, String) { + use comments <- result.try(issue_comments(cwd, pr_number, log_path)) + let marker = pr_handoff.comment_marker(task_id) + + case list.find(comments, fn(comment) { + string.contains(does: comment.body, contain: marker) + }) { + Ok(comment) -> { + use _ <- result.try(update_issue_comment(cwd, comment.id, body, log_path)) + Ok(CommentUpdated) + } + Error(_) -> { + use _ <- result.try(create_issue_comment(cwd, pr_number, body, log_path)) + Ok(CommentCreated) + } + } +} + /// List open pull requests created by the configured Night Shift branch prefix. pub fn list_night_shift_prs( cwd: String, @@ -188,6 +237,78 @@ fn find_pull_request( }) } +fn existing_pr_body( + cwd: String, + pr_number: Int, + log_path: String, +) -> Result(String, String) { + let command = + gh_pr_command("view ") + <> int.to_string(pr_number) + <> " --json body" + + let result = shell.run(command, cwd, log_path) + case shell.succeeded(result) { + True -> + json.parse(result.output, pull_request_body_decoder()) + |> result.map_error(fn(_) { "Unable to decode pull request body." }) + False -> Error("Unable to inspect pull request body.") + } +} + +fn compose_pr_body( + existing_body: String, + legacy_body: String, + handoff_region: Option(String), + handoff: types.HandoffConfig, +) -> String { + case handoff.enabled, handoff.pr_body_mode, handoff_region { + False, _, _ -> legacy_body + _, types.HandoffBodyOff, _ -> legacy_body + _, _, None -> legacy_body + _, mode, Some(region) -> + case replace_handoff_region(existing_body, region) { + Some(updated) -> updated + None -> { + let base = first_non_empty(existing_body, legacy_body) + case string.trim(base) { + "" -> region + _ -> + case mode { + types.HandoffBodyPrepend -> region <> "\n\n" <> base + _ -> base <> "\n\n" <> region + } + } + } + } + } +} + +fn replace_handoff_region(existing_body: String, next_region: String) -> Option(String) { + case + string.split_once(existing_body, pr_handoff.body_start_marker) + { + Ok(#(before, remainder)) -> + case string.split_once(remainder, pr_handoff.body_end_marker) { + Ok(#(_, after)) -> + Some( + before + <> next_region + <> after, + ) + Error(_) -> None + } + Error(_) -> None + } +} + +fn first_non_empty(left: String, right: String) -> String { + case string.trim(left) { + "" -> right + _ -> left + } +} + fn create_pull_request( cwd: String, branch_name: String, @@ -228,6 +349,57 @@ fn comment_pull_request( run_gh(command, cwd, log_path) } +fn create_issue_comment( + cwd: String, + pr_number: Int, + body: String, + log_path: String, +) -> Result(Nil, String) { + let command = + gh_api_command( + "repos/:owner/:repo/issues/" <> int.to_string(pr_number) <> "/comments", + ) + <> " --method POST --raw-field body=" + <> shell.quote(body) + + run_gh(command, cwd, log_path) +} + +fn update_issue_comment( + cwd: String, + comment_id: Int, + body: String, + log_path: String, +) -> Result(Nil, String) { + let command = + gh_api_command( + "repos/:owner/:repo/issues/comments/" <> int.to_string(comment_id), + ) + <> " --method PATCH --raw-field body=" + <> shell.quote(body) + + run_gh(command, cwd, log_path) +} + +fn issue_comments( + cwd: String, + pr_number: Int, + log_path: String, +) -> Result(List(IssueComment), String) { + let command = + gh_api_command( + "repos/:owner/:repo/issues/" <> int.to_string(pr_number) <> "/comments", + ) + + let result = shell.run(command, cwd, log_path) + case shell.succeeded(result) { + True -> + json.parse(result.output, decode.list(issue_comment_decoder())) + |> result.map_error(fn(_) { "Unable to decode issue comments." }) + False -> Error("Unable to inspect issue comments.") + } +} + fn close_pull_request( cwd: String, pr_number: Int, @@ -267,6 +439,10 @@ fn gh_pr_command(args: String) -> String { gh_executable() <> " pr " <> args } +fn gh_api_command(path: String) -> String { + gh_executable() <> " api " <> shell.quote(path) +} + fn gh_executable() -> String { case system.get_env("NIGHT_SHIFT_GH_BIN") { "" -> "gh" @@ -419,6 +595,17 @@ fn comment_decoder() -> decode.Decoder(String) { decode.success("Comment: " <> body) } +fn pull_request_body_decoder() -> decode.Decoder(String) { + use body <- decode.field("body", decode.string) + decode.success(body) +} + +fn issue_comment_decoder() -> decode.Decoder(IssueComment) { + use id <- decode.field("id", decode.int) + use body <- decode.field("body", decode.string) + decode.success(IssueComment(id: id, body: body)) +} + fn review_work_item_snapshot( review_item: ReviewWorkItem, ) -> repo_state.RepoPullRequestSnapshot { diff --git a/src/night_shift/infra/run_store.gleam b/src/night_shift/infra/run_store.gleam index 976b464..17d5403 100644 --- a/src/night_shift/infra/run_store.gleam +++ b/src/night_shift/infra/run_store.gleam @@ -105,6 +105,7 @@ pub fn create_pending_run_with_context( created_at: timestamp, updated_at: timestamp, tasks: [], + handoff_states: [], ) case save(run, []) { diff --git a/src/night_shift/infra/task_delivery.gleam b/src/night_shift/infra/task_delivery.gleam index 80a8fc5..bf81a51 100644 --- a/src/night_shift/infra/task_delivery.gleam +++ b/src/night_shift/infra/task_delivery.gleam @@ -1,19 +1,33 @@ import filepath import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result +import gleam/string +import night_shift/domain/pr_handoff import night_shift/domain/pull_request as pull_request_domain import night_shift/domain/summary as domain_summary import night_shift/git import night_shift/github import night_shift/provider +import night_shift/repo_state_runtime +import night_shift/system import night_shift/types +import simplifile pub type DeliveryOutcome { NoDeliveredChanges(delivered_files: List(String)) - Delivered(pr_number: String, pr_url: String, delivered_files: List(String)) + Delivered( + pr_number: String, + pr_url: String, + delivered_files: List(String), + handoff_state: types.TaskHandoffState, + handoff_events: List(types.RunEvent), + ) } pub fn deliver_completed_task( + config: types.Config, run: types.RunRecord, task_run: provider.TaskRun, execution_result: types.ExecutionResult, @@ -41,20 +55,38 @@ pub fn deliver_completed_task( git.push_branch(task_run.worktree_path, task_run.branch_name, git_log) |> result.map_error(git_delivery_error), ) - let pr_body = - pull_request_domain.render_body( + let snippets_and_events = load_snippets(run.repo_root, config.handoff, task_run.task.id) + let #(snippets, snippet_events) = snippets_and_events + let legacy_body = + pull_request_domain.render_legacy_body( run, task_run.task, execution_result, verification_output, ) + let handoff_region = case + pr_handoff.body_region_enabled(config.handoff) + { + True -> + Some(pr_handoff.render_body_region( + config.handoff, + run, + task_run.task, + execution_result, + verification_output, + snippets, + )) + False -> None + } use pull_request <- result.try( github.open_or_update_pr( task_run.worktree_path, task_run.branch_name, task_run.base_ref, execution_result.pr.title, - pr_body, + legacy_body, + handoff_region, + config.handoff, run.run_path, git_log, ) @@ -65,10 +97,126 @@ pub fn deliver_completed_task( ) }), ) + let repo_state_status = case config.handoff.managed_comment { + True -> + case repo_state_runtime.inspect(run, config.branch_prefix).view { + Some(view) -> + Some(pr_handoff.RepoStateStatus( + drift: repo_state_runtime.drift_label(view.drift), + open_pr_count: view.open_pr_count, + actionable_pr_count: view.actionable_pr_count, + )) + None -> None + } + False -> None + } + let previous_state = + types.task_handoff_state(run.handoff_states, task_run.task.id) + let #(managed_comment_present, comment_events) = case + config.handoff.enabled && config.handoff.managed_comment + { + True -> { + let comment_body = + pr_handoff.render_managed_comment( + run, + task_run.task, + execution_result, + verification_output, + previous_state, + repo_state_status, + snippets, + ) + case + github.upsert_handoff_comment( + task_run.worktree_path, + pull_request.number, + task_run.task.id, + comment_body, + git_log, + ) + { + Ok(github.CommentCreated) -> + #( + True, + [handoff_event( + "pr_handoff_created", + task_run.task.id, + "Created managed PR handoff comment for PR #" + <> int.to_string(pull_request.number) + <> ".", + )], + ) + Ok(github.CommentUpdated) -> + #( + True, + [handoff_event( + "pr_handoff_updated", + task_run.task.id, + "Updated managed PR handoff comment for PR #" + <> int.to_string(pull_request.number) + <> ".", + )], + ) + Error(message) -> + #( + False, + [handoff_event( + "pr_handoff_warning", + task_run.task.id, + "Unable to update the managed PR handoff comment: " <> message, + )], + ) + } + } + False -> #(False, []) + } + let body_region_present = case handoff_region { + Some(_) -> True + None -> False + } + let handoff_state = + types.TaskHandoffState( + task_id: task_run.task.id, + delivered_pr_number: int.to_string(pull_request.number), + last_delivered_commit_sha: delivered_head, + last_handoff_files: execution_result.files_touched, + last_verification_digest: pr_handoff.verification_digest( + verification_output, + ), + last_risks: execution_result.pr.risks, + last_handoff_updated_at: system.timestamp(), + body_region_present: body_region_present, + managed_comment_present: managed_comment_present, + ) + let base_handoff_event = case previous_state { + Some(_) -> + [handoff_event( + "pr_handoff_updated", + task_run.task.id, + case body_region_present { + True -> "Updated Night Shift PR handoff metadata." + False -> "Persisted Night Shift PR handoff state." + }, + )] + None -> + [handoff_event( + "pr_handoff_created", + task_run.task.id, + case body_region_present { + True -> "Created Night Shift PR handoff metadata." + False -> "Created Night Shift PR handoff state." + }, + )] + } Ok(Delivered( pr_number: int.to_string(pull_request.number), pr_url: pull_request.url, delivered_files: delivered_files, + handoff_state: handoff_state, + handoff_events: list.append( + list.append(snippet_events, base_handoff_event), + comment_events, + ), )) } } @@ -93,3 +241,69 @@ fn commit_worktree_changes( fn git_delivery_error(message: String) -> String { domain_summary.task_failure_summary("git delivery failed.", message) } + +fn load_snippets( + repo_root: String, + handoff: types.HandoffConfig, + task_id: String, +) -> #(pr_handoff.Snippets, List(types.RunEvent)) { + let #(body_prefix, body_prefix_events) = + load_snippet(repo_root, handoff.pr_body_prefix_path, task_id) + let #(body_suffix, body_suffix_events) = + load_snippet(repo_root, handoff.pr_body_suffix_path, task_id) + let #(comment_prefix, comment_prefix_events) = + load_snippet(repo_root, handoff.comment_prefix_path, task_id) + let #(comment_suffix, comment_suffix_events) = + load_snippet(repo_root, handoff.comment_suffix_path, task_id) + + #( + pr_handoff.Snippets( + body_prefix: body_prefix, + body_suffix: body_suffix, + comment_prefix: comment_prefix, + comment_suffix: comment_suffix, + ), + list.append( + list.append(body_prefix_events, body_suffix_events), + list.append(comment_prefix_events, comment_suffix_events), + ), + ) +} + +fn load_snippet( + repo_root: String, + configured_path: Option(String), + task_id: String, +) -> #(Option(String), List(types.RunEvent)) { + case configured_path { + None -> #(None, []) + Some(path) -> { + let absolute_path = case string.starts_with(path, "/") { + True -> path + False -> filepath.join(repo_root, path) + } + + case simplifile.read(absolute_path) { + Ok(contents) -> #(Some(contents), []) + Error(_) -> + #( + None, + [handoff_event( + "pr_handoff_warning", + task_id, + "Unable to read handoff snippet at " <> path <> ".", + )], + ) + } + } + } +} + +fn handoff_event(kind: String, task_id: String, message: String) -> types.RunEvent { + types.RunEvent( + kind: kind, + at: system.timestamp(), + message: message, + task_id: Some(task_id), + ) +} diff --git a/src/night_shift/orchestrator/execution_phase.gleam b/src/night_shift/orchestrator/execution_phase.gleam index 028f053..516a227 100644 --- a/src/night_shift/orchestrator/execution_phase.gleam +++ b/src/night_shift/orchestrator/execution_phase.gleam @@ -413,6 +413,7 @@ fn finalize_success( Ok(verified) -> case task_delivery.deliver_completed_task( + config, run, task_run, verified.execution_result, @@ -427,7 +428,13 @@ fn finalize_success( "Primary blocker: provider reported completion but the task worktree produced no committed or uncommitted changes.\n\nEnvironment notes: verification completed, but there was nothing new to deliver.", "task_manual_attention", ) - Ok(task_delivery.Delivered(pr_number, pr_url, delivered_files)) -> { + Ok(task_delivery.Delivered( + pr_number, + pr_url, + delivered_files, + handoff_state, + handoff_events, + )) -> { let completed_task = types.Task( ..task_run.task, @@ -447,7 +454,15 @@ fn finalize_success( run.decisions, ) |> task_graph.refresh_ready_states - let updated_run = types.RunRecord(..run, tasks: merged_tasks) + let updated_run = + types.RunRecord( + ..run, + tasks: merged_tasks, + handoff_states: types.replace_task_handoff_state( + run.handoff_states, + handoff_state, + ), + ) let verified_event = types.RunEvent( kind: "task_verified", @@ -459,7 +474,7 @@ fn finalize_success( updated_run, verified_event, )) - journal.append_event( + use delivered_run <- result.try(journal.append_event( types.RunRecord( ..verified_run, tasks: task_graph.replace_task( @@ -478,7 +493,8 @@ fn finalize_success( message: pr_url, task_id: Some(task_run.task.id), ), - ) + )) + append_events(delivered_run, handoff_events) } Error(message) -> Error(message) } @@ -495,6 +511,19 @@ fn finalize_success( } } +fn append_events( + run: types.RunRecord, + events: List(types.RunEvent), +) -> Result(types.RunRecord, String) { + case events { + [] -> Ok(run) + [event, ..rest] -> { + use updated_run <- result.try(journal.append_event(run, event)) + append_events(updated_run, rest) + } + } +} + fn append_manual_attention_events( run: types.RunRecord, ) -> Result(types.RunRecord, String) { diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index 2803b47..1229cd9 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -456,6 +456,7 @@ pub type RunRecord { created_at: String, updated_at: String, tasks: List(Task), + handoff_states: List(TaskHandoffState), ) } @@ -465,6 +466,125 @@ pub type RunSelector { RunId(String) } +pub type HandoffBodyMode { + HandoffBodyOff + HandoffBodyAppend + HandoffBodyPrepend +} + +pub fn handoff_body_mode_from_string( + value: String, +) -> Result(HandoffBodyMode, String) { + case value { + "off" -> Ok(HandoffBodyOff) + "append" -> Ok(HandoffBodyAppend) + "prepend" -> Ok(HandoffBodyPrepend) + _ -> Error("Unsupported handoff PR body mode: " <> value) + } +} + +pub fn handoff_body_mode_to_string(mode: HandoffBodyMode) -> String { + case mode { + HandoffBodyOff -> "off" + HandoffBodyAppend -> "append" + HandoffBodyPrepend -> "prepend" + } +} + +pub type HandoffProvenance { + HandoffProvenanceMinimal + HandoffProvenanceLight + HandoffProvenanceStructured +} + +pub fn handoff_provenance_from_string( + value: String, +) -> Result(HandoffProvenance, String) { + case value { + "minimal" -> Ok(HandoffProvenanceMinimal) + "light" -> Ok(HandoffProvenanceLight) + "structured" -> Ok(HandoffProvenanceStructured) + _ -> Error("Unsupported handoff provenance level: " <> value) + } +} + +pub fn handoff_provenance_to_string(level: HandoffProvenance) -> String { + case level { + HandoffProvenanceMinimal -> "minimal" + HandoffProvenanceLight -> "light" + HandoffProvenanceStructured -> "structured" + } +} + +pub type HandoffConfig { + HandoffConfig( + enabled: Bool, + pr_body_mode: HandoffBodyMode, + managed_comment: Bool, + provenance: HandoffProvenance, + include_files_touched: Bool, + include_acceptance: Bool, + include_stack_context: Bool, + include_verification_summary: Bool, + pr_body_prefix_path: Option(String), + pr_body_suffix_path: Option(String), + comment_prefix_path: Option(String), + comment_suffix_path: Option(String), + ) +} + +pub fn default_handoff_config() -> HandoffConfig { + HandoffConfig( + enabled: True, + pr_body_mode: HandoffBodyAppend, + managed_comment: False, + provenance: HandoffProvenanceStructured, + include_files_touched: True, + include_acceptance: False, + include_stack_context: True, + include_verification_summary: True, + pr_body_prefix_path: None, + pr_body_suffix_path: None, + comment_prefix_path: None, + comment_suffix_path: None, + ) +} + +pub type TaskHandoffState { + TaskHandoffState( + task_id: String, + delivered_pr_number: String, + last_delivered_commit_sha: String, + last_handoff_files: List(String), + last_verification_digest: String, + last_risks: List(String), + last_handoff_updated_at: String, + body_region_present: Bool, + managed_comment_present: Bool, + ) +} + +pub fn task_handoff_state( + handoff_states: List(TaskHandoffState), + task_id: String, +) -> Option(TaskHandoffState) { + case handoff_states |> list.find(fn(state) { state.task_id == task_id }) { + Ok(state) -> Some(state) + Error(_) -> None + } +} + +pub fn replace_task_handoff_state( + handoff_states: List(TaskHandoffState), + next_state: TaskHandoffState, +) -> List(TaskHandoffState) { + case handoff_states { + [] -> [next_state] + [state, ..rest] if state.task_id == next_state.task_id -> [next_state, ..rest] + [state, ..rest] -> [state, ..replace_task_handoff_state(rest, next_state)] + } +} + /// Repo-local operator configuration for Night Shift. pub type Config { Config( @@ -479,6 +599,7 @@ pub type Config { pr_title_prefix: String, verification_commands: List(String), notifiers: List(NotifierName), + handoff: HandoffConfig, ) } @@ -496,6 +617,7 @@ pub fn default_config() -> Config { pr_title_prefix: "[night-shift]", verification_commands: [], notifiers: [ConsoleNotifier, ReportFileNotifier], + handoff: default_handoff_config(), ) } diff --git a/test/domain_pr_handoff_test.gleam b/test/domain_pr_handoff_test.gleam new file mode 100644 index 0000000..86f6a9e --- /dev/null +++ b/test/domain_pr_handoff_test.gleam @@ -0,0 +1,147 @@ +import gleam/option.{None, Some} +import gleam/string +import night_shift/domain/pr_handoff +import night_shift/types +import night_shift_test_support + +pub fn render_body_region_includes_sections_and_snippets_test() { + let handoff = + types.HandoffConfig( + ..types.default_handoff_config(), + include_acceptance: True, + ) + let body = + pr_handoff.render_body_region( + handoff, + sample_run(), + sample_task(), + sample_execution_result(), + "$ gleam test", + pr_handoff.Snippets( + body_prefix: Some("Team prefix"), + body_suffix: Some("Team suffix"), + comment_prefix: None, + comment_suffix: None, + ), + ) + + assert string.contains(body, pr_handoff.body_start_marker) + assert string.contains(body, "Team prefix") + assert string.contains(body, "## Context") + assert string.contains(body, "## Scope") + assert string.contains(body, "Files touched: src/app.gleam, test/app_test.gleam") + assert string.contains(body, "Acceptance: Add the app entrypoint, Cover the happy path") + assert string.contains(body, "## Evidence") + assert string.contains(body, "Verification digest:") + assert string.contains(body, "## Provenance") + assert string.contains(body, "Team suffix") + assert string.contains(body, pr_handoff.body_end_marker) +} + +pub fn render_managed_comment_reports_delta_and_review_context_test() { + let comment = + pr_handoff.render_managed_comment( + sample_review_run(), + sample_task(), + sample_execution_result(), + "$ gleam test", + Some(types.TaskHandoffState( + task_id: "task-1", + delivered_pr_number: "15", + last_delivered_commit_sha: "abc123", + last_handoff_files: ["src/old.gleam"], + last_verification_digest: "old-digest", + last_risks: ["Old risk"], + last_handoff_updated_at: "2026-04-13T17:00:00Z", + body_region_present: True, + managed_comment_present: True, + )), + Some(pr_handoff.RepoStateStatus( + drift: "yes", + open_pr_count: 3, + actionable_pr_count: 1, + )), + pr_handoff.empty_snippets(), + ) + + assert string.contains(comment, "## Since Last Review") + assert string.contains(comment, "Added files: src/app.gleam, test/app_test.gleam") + assert string.contains(comment, "Removed files: src/old.gleam") + assert string.contains(comment, "Verification changed: yes") + assert string.contains(comment, "## Review Feedback Status") + assert string.contains(comment, "#11: Review COMMENTED: Please make QA_NOTES.md the canonical doc.") + assert string.contains(comment, "## Stack / Replacement Status") + assert string.contains(comment, "Repo-state drift: yes") + assert string.contains(comment, pr_handoff.comment_marker("task-1")) +} + +fn sample_run() -> types.RunRecord { + types.RunRecord( + run_id: "run-123", + repo_root: "/repo", + run_path: "/repo/.night-shift/runs/run-123", + brief_path: "/repo/.night-shift/execution-brief.md", + state_path: "", + events_path: "", + report_path: "", + lock_path: "", + planning_agent: types.resolved_agent_from_provider(types.Codex), + execution_agent: types.resolved_agent_from_provider(types.Codex), + environment_name: "", + max_workers: 1, + notes_source: None, + planning_provenance: Some(types.NotesOnly(types.NotesFile("notes.md"))), + repo_state_snapshot: None, + decisions: [], + planning_dirty: False, + status: types.RunPending, + created_at: "", + updated_at: "", + tasks: [], + handoff_states: [], + ) +} + +fn sample_review_run() -> types.RunRecord { + types.RunRecord( + ..sample_run(), + planning_provenance: Some(types.ReviewsOnly), + repo_state_snapshot: Some(night_shift_test_support.sample_repo_state_snapshot()), + ) +} + +fn sample_task() -> types.Task { + types.Task( + id: "task-1", + title: "Task 1", + description: "Add the new app entrypoint.", + dependencies: [], + acceptance: ["Add the app entrypoint", "Cover the happy path"], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [11, 12], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Ready, + worktree_path: "", + branch_name: "night-shift/task-1", + pr_number: "15", + summary: "", + ) +} + +fn sample_execution_result() -> types.ExecutionResult { + types.ExecutionResult( + status: types.Completed, + summary: "Completed the app task.", + files_touched: ["src/app.gleam", "test/app_test.gleam"], + demo_evidence: ["Ran the app entrypoint"], + pr: types.PrPlan( + title: "Task 1", + summary: "Adds the app entrypoint.", + demo: ["Ran the app entrypoint"], + risks: ["Docs follow-up remains."], + ), + follow_up_tasks: [], + ) +} diff --git a/test/domain_pull_request_test.gleam b/test/domain_pull_request_test.gleam index 01dcf2f..3064f9a 100644 --- a/test/domain_pull_request_test.gleam +++ b/test/domain_pull_request_test.gleam @@ -125,6 +125,7 @@ fn sample_run() -> types.RunRecord { created_at: "", updated_at: "", tasks: [], + handoff_states: [], ) } diff --git a/test/domain_report_test.gleam b/test/domain_report_test.gleam index 59a12c2..79de6a3 100644 --- a/test/domain_report_test.gleam +++ b/test/domain_report_test.gleam @@ -114,6 +114,7 @@ fn review_run() -> types.RunRecord { "/tmp/repo/.night-shift/runs/review-run/worktrees/refresh-links", ), ], + handoff_states: [], ) } diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index e6cd2e9..eac44e3 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -207,3 +207,58 @@ pub fn parse_notifiers_and_verification_commands_test() { assert parsed.notifiers == [types.ConsoleNotifier, types.ReportFileNotifier] assert parsed.verification_commands == ["gleam test", "npm test"] } + +pub fn parse_handoff_config_test() { + let source = + "[handoff]\n" + <> "enabled = false\n" + <> "pr_body_mode = \"prepend\"\n" + <> "managed_comment = true\n" + <> "provenance = \"light\"\n" + <> "include_files_touched = false\n" + <> "include_acceptance = true\n" + <> "include_stack_context = false\n" + <> "include_verification_summary = false\n" + <> "pr_body_prefix_path = \".night-shift/pr-prefix.md\"\n" + <> "comment_suffix_path = \".night-shift/comment-suffix.md\"\n" + + let assert Ok(parsed) = config.parse(source) + + assert parsed.handoff.enabled == False + assert parsed.handoff.pr_body_mode == types.HandoffBodyPrepend + assert parsed.handoff.managed_comment == True + assert parsed.handoff.provenance == types.HandoffProvenanceLight + assert parsed.handoff.include_files_touched == False + assert parsed.handoff.include_acceptance == True + assert parsed.handoff.include_stack_context == False + assert parsed.handoff.include_verification_summary == False + assert parsed.handoff.pr_body_prefix_path == Some(".night-shift/pr-prefix.md") + assert parsed.handoff.comment_suffix_path + == Some(".night-shift/comment-suffix.md") +} + +pub fn render_handoff_config_round_trip_test() { + let configured = + types.Config( + ..types.default_config(), + handoff: types.HandoffConfig( + enabled: True, + pr_body_mode: types.HandoffBodyPrepend, + managed_comment: True, + provenance: types.HandoffProvenanceMinimal, + include_files_touched: False, + include_acceptance: True, + include_stack_context: False, + include_verification_summary: False, + pr_body_prefix_path: Some(".night-shift/pr-prefix.md"), + pr_body_suffix_path: None, + comment_prefix_path: Some(".night-shift/comment-prefix.md"), + comment_suffix_path: None, + ), + ) + + let rendered = config.render(configured) + let assert Ok(parsed) = config.parse(rendered) + + assert parsed.handoff == configured.handoff +} diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 4747eb8..9c9aa5c 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -12,6 +12,7 @@ import night_shift/shell import night_shift/system import night_shift/types import night_shift/worktree_setup +import night_shift/domain/pr_handoff import night_shift_test_support as support import simplifile @@ -58,6 +59,8 @@ pub fn github_open_or_update_pr_uses_create_output_when_listing_lags_test() { "main", "Demo PR", "Body", + None, + types.default_handoff_config(), run_path, filepath.join(run_path, "logs/gh.log"), ) @@ -74,6 +77,132 @@ pub fn github_open_or_update_pr_uses_create_output_when_listing_lags_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn github_open_or_update_pr_preserves_manual_body_outside_handoff_region_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-gh-handoff-body-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let run_path = filepath.join(base_dir, "run") + let bin_dir = filepath.join(base_dir, "bin") + let fake_gh = filepath.join(bin_dir, "gh") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let body_file = fake_gh <> ".body.txt" + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = + simplifile.create_directory_all(filepath.join(run_path, "logs")) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + support.seed_git_repo(repo_root, base_dir) + let _ = + shell.run( + "git checkout -b night-shift/demo-branch", + repo_root, + filepath.join(base_dir, "branch.log"), + ) + let assert Ok(_) = support.write_handoff_fake_gh(fake_gh) + let assert Ok(_) = + simplifile.write( + "Manual intro\n\n" + <> "\nold body\n\n\n" + <> "Manual footer\n", + to: body_file, + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) + + let result = + github.open_or_update_pr( + repo_root, + "night-shift/demo-branch", + "main", + "Demo PR", + "Legacy body", + Some("\nnew body\n"), + types.default_handoff_config(), + run_path, + filepath.join(run_path, "logs/gh.log"), + ) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + + let assert Ok(_) = result + let assert Ok(updated_body) = simplifile.read(body_file) + + assert string.contains(updated_body, "Manual intro") + assert string.contains(updated_body, "new body") + assert string.contains(updated_body, "Manual footer") + assert !string.contains(does: updated_body, contain: "old body") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn github_upsert_handoff_comment_updates_existing_comment_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-gh-handoff-comment-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let bin_dir = filepath.join(base_dir, "bin") + let fake_gh = filepath.join(bin_dir, "gh") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let comment_file = fake_gh <> ".comment.txt" + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = support.write_handoff_fake_gh(fake_gh) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) + + let assert Ok(github.CommentCreated) = + github.upsert_handoff_comment( + repo_root, + 1, + "task-1", + "First body\n\n" <> pr_handoff.comment_marker("task-1"), + filepath.join(base_dir, "gh-create.log"), + ) + let assert Ok(github.CommentUpdated) = + github.upsert_handoff_comment( + repo_root, + 1, + "task-1", + "Second body\n\n" <> pr_handoff.comment_marker("task-1"), + filepath.join(base_dir, "gh-update.log"), + ) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + + let assert Ok(comment_body) = simplifile.read(comment_file) + assert string.contains(comment_body, "Second body") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn orchestrator_start_runs_fake_provider_test() { let unique = system.unique_id() let base_dir = diff --git a/test/night_shift_persistence_provider_test.gleam b/test/night_shift_persistence_provider_test.gleam index fecaf44..b875f37 100644 --- a/test/night_shift_persistence_provider_test.gleam +++ b/test/night_shift_persistence_provider_test.gleam @@ -381,6 +381,7 @@ pub fn plan_command_non_tty_streaming_stays_plain_test() { let old_path = system.get_env("PATH") let old_state_home = system.get_env("XDG_STATE_HOME") let old_stream_ui = system.get_env("NIGHT_SHIFT_STREAM_UI") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") let _ = simplifile.delete(file_or_dir_at: base_dir) let _ = @@ -401,6 +402,7 @@ pub fn plan_command_non_tty_streaming_stays_plain_test() { system.set_env("PATH", bin_dir <> ":" <> old_path) system.set_env("XDG_STATE_HOME", state_home) system.set_env("NIGHT_SHIFT_STREAM_UI", "auto") + system.unset_env("NIGHT_SHIFT_FAKE_PROVIDER") let result = support.run_local_cli_command( @@ -412,6 +414,7 @@ pub fn plan_command_non_tty_streaming_stays_plain_test() { system.set_env("PATH", old_path) support.restore_env("XDG_STATE_HOME", old_state_home) support.restore_env("NIGHT_SHIFT_STREAM_UI", old_stream_ui) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) let assert Ok(output) = result assert !string.contains(does: output, contain: "\u{001b}") @@ -440,6 +443,7 @@ pub fn plan_command_streaming_handles_utf8_tool_output_truncation_test() { let old_path = system.get_env("PATH") let old_state_home = system.get_env("XDG_STATE_HOME") let old_stream_ui = system.get_env("NIGHT_SHIFT_STREAM_UI") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") let _ = simplifile.delete(file_or_dir_at: base_dir) let _ = @@ -460,6 +464,7 @@ pub fn plan_command_streaming_handles_utf8_tool_output_truncation_test() { system.set_env("PATH", bin_dir <> ":" <> old_path) system.set_env("XDG_STATE_HOME", state_home) system.set_env("NIGHT_SHIFT_STREAM_UI", "auto") + system.unset_env("NIGHT_SHIFT_FAKE_PROVIDER") let result = support.run_local_cli_command( @@ -471,6 +476,7 @@ pub fn plan_command_streaming_handles_utf8_tool_output_truncation_test() { system.set_env("PATH", old_path) support.restore_env("XDG_STATE_HOME", old_state_home) support.restore_env("NIGHT_SHIFT_STREAM_UI", old_stream_ui) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) let assert Ok(output) = result assert string.contains(does: output, contain: "Planned run ") @@ -491,6 +497,7 @@ pub fn plan_command_tty_streaming_restores_alt_screen_test() { let notes_path = filepath.join(base_dir, "notes.md") let fake_codex = filepath.join(bin_dir, "codex") let state_home = filepath.join(base_dir, "state") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") let _ = simplifile.delete(file_or_dir_at: base_dir) let _ = @@ -516,6 +523,8 @@ pub fn plan_command_tty_streaming_restores_alt_screen_test() { <> shell.quote(bin_dir <> ":" <> system.get_env("PATH")) <> " XDG_STATE_HOME=" <> shell.quote(state_home) + <> " NIGHT_SHIFT_FAKE_PROVIDER=" + <> shell.quote("") <> " NIGHT_SHIFT_REPO_ROOT=" <> shell.quote(repo_root) <> " NIGHT_SHIFT_STREAM_UI=tui " @@ -534,6 +543,8 @@ pub fn plan_command_tty_streaming_restores_alt_screen_test() { assert string.contains(does: output.output, contain: "\u{001b}[?1049h") assert string.contains(does: output.output, contain: "\u{001b}[?1049l") + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) + let _ = simplifile.delete(file_or_dir_at: base_dir) } diff --git a/test/night_shift_test_support.gleam b/test/night_shift_test_support.gleam index c5f5d3e..2fe5fbf 100644 --- a/test/night_shift_test_support.gleam +++ b/test/night_shift_test_support.gleam @@ -1119,3 +1119,92 @@ pub fn write_branch_sensitive_fake_gh( to: path, ) } + +pub fn write_handoff_fake_gh(path: String) -> Result(Nil, simplifile.FileError) { + simplifile.write( + "#!/bin/sh\n" + <> "BODY_FILE=\"$0.body.txt\"\n" + <> "COMMENT_FILE=\"$0.comment.txt\"\n" + <> "if [ ! -f \"$BODY_FILE\" ]; then\n" + <> " printf 'Legacy body\\n' > \"$BODY_FILE\"\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n" + <> " BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'night-shift/demo')\n" + <> " printf '[{\"number\":1,\"url\":\"https://example.test/pr/1\",\"headRefName\":\"%s\",\"title\":\"Night Shift PR\"}]\\n' \"$BRANCH\"\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n" + <> " if [ \"$4\" = \"--json\" ] && [ \"$5\" = \"body\" ]; then\n" + <> " python3 - <<'PY' \"$BODY_FILE\"\n" + <> "import json, sys\n" + <> "body = open(sys.argv[1]).read()\n" + <> "print(json.dumps({'body': body}))\n" + <> "PY\n" + <> " exit 0\n" + <> " fi\n" + <> " printf '{\"number\":1,\"title\":\"Night Shift PR\",\"body\":\"Review body\",\"headRefName\":\"night-shift/demo\",\"baseRefName\":\"main\",\"url\":\"https://example.test/pr/1\",\"reviewDecision\":\"REVIEW_REQUIRED\",\"statusCheckRollup\":[],\"reviews\":[],\"comments\":[]}'\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"edit\" ]; then\n" + <> " shift 2\n" + <> " while [ $# -gt 0 ]; do\n" + <> " case \"$1\" in\n" + <> " --body-file)\n" + <> " cp \"$2\" \"$BODY_FILE\"\n" + <> " shift 2\n" + <> " ;;\n" + <> " *)\n" + <> " shift\n" + <> " ;;\n" + <> " esac\n" + <> " done\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"api\" ]; then\n" + <> " PATH_ARG=$2\n" + <> " METHOD=GET\n" + <> " BODY=''\n" + <> " shift 2\n" + <> " while [ $# -gt 0 ]; do\n" + <> " case \"$1\" in\n" + <> " --method)\n" + <> " METHOD=$2\n" + <> " shift 2\n" + <> " ;;\n" + <> " --raw-field)\n" + <> " BODY=${2#body=}\n" + <> " shift 2\n" + <> " ;;\n" + <> " *)\n" + <> " shift\n" + <> " ;;\n" + <> " esac\n" + <> " done\n" + <> " case \"$METHOD:$PATH_ARG\" in\n" + <> " GET:repos/:owner/:repo/issues/1/comments)\n" + <> " python3 - <<'PY' \"$COMMENT_FILE\"\n" + <> "import json, os, sys\n" + <> "path = sys.argv[1]\n" + <> "if not os.path.exists(path):\n" + <> " print('[]')\n" + <> "else:\n" + <> " body = open(path).read()\n" + <> " print(json.dumps([{'id': 1, 'body': body}]))\n" + <> "PY\n" + <> " exit 0\n" + <> " ;;\n" + <> " POST:repos/:owner/:repo/issues/1/comments)\n" + <> " printf '%s' \"$BODY\" > \"$COMMENT_FILE\"\n" + <> " exit 0\n" + <> " ;;\n" + <> " PATCH:repos/:owner/:repo/issues/comments/1)\n" + <> " printf '%s' \"$BODY\" > \"$COMMENT_FILE\"\n" + <> " exit 0\n" + <> " ;;\n" + <> " esac\n" + <> "fi\n" + <> "printf 'unsupported gh invocation: %s %s\\n' \"$1\" \"$2\" >&2\n" + <> "exit 1\n", + to: path, + ) +} From 112066cb97bd16a04e055617eb326138210b19b0 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:26:30 -0700 Subject: [PATCH 03/13] Add runtime identity to worktree setup and run artifacts --- .codex/skills/qa-night-shift/SKILL.md | 8 + docs/getting-started.md | 12 +- docs/state-and-artifacts.md | 20 ++ docs/worktree-environments.md | 65 +++++ src/night_shift/codec/journal.gleam | 60 ++++ src/night_shift/codec/provider_payload.gleam | 1 + src/night_shift/codec/worktree_setup.gleam | 110 +++++++- src/night_shift/demo.gleam | 55 +++- src/night_shift/domain/pull_request.gleam | 2 + src/night_shift/domain/report.gleam | 19 ++ src/night_shift/domain/status.gleam | 12 + src/night_shift/domain/task_graph.gleam | 2 + src/night_shift/infra/task_verifier.gleam | 1 + .../orchestrator/execution_phase.gleam | 72 ++++- src/night_shift/project.gleam | 10 + src/night_shift/provider_prompt.gleam | 37 +++ src/night_shift/runtime_identity.gleam | 253 +++++++++++++++++ src/night_shift/types.gleam | 20 ++ src/night_shift/worktree_setup.gleam | 10 +- src/night_shift/worktree_setup_model.gleam | 10 + src/night_shift/worktree_setup_runtime.gleam | 57 ++-- src/night_shift_runtime_identity_ffi.erl | 64 +++++ test/domain_decision_contract_test.gleam | 2 + test/domain_decision_test.gleam | 3 +- test/domain_plan_hygiene_test.gleam | 6 + test/domain_pull_request_test.gleam | 1 + test/domain_report_test.gleam | 1 + test/domain_review_lineage_test.gleam | 2 + test/domain_run_state_test.gleam | 2 + test/domain_task_graph_test.gleam | 4 + test/domain_task_validation_test.gleam | 2 + test/night_shift_dashboard_demo_test.gleam | 2 +- .../night_shift_execution_delivery_test.gleam | 190 ++++++++++++- test/night_shift_lifecycle_test.gleam | 1 + test/night_shift_test_support.gleam | 69 +++-- test/runtime_identity_test.gleam | 256 ++++++++++++++++++ 36 files changed, 1385 insertions(+), 56 deletions(-) create mode 100644 src/night_shift/runtime_identity.gleam create mode 100644 src/night_shift_runtime_identity_ffi.erl create mode 100644 test/runtime_identity_test.gleam diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 5eebb72..d25e279 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -167,6 +167,14 @@ In review-driven runs, pay attention to repo-state evidence: manual attention - whether `status` and `report` show payload-repair attempts, successes, and failures with usable artifact paths +- whether prepared tasks get runtime identity evidence in `status`, `report`, + and `.night-shift/runs//runtime//` +- whether `night-shift.env`, `night-shift.runtime.json`, and + `night-shift.handoff.md` exist under the run runtime directory instead of + inside the git worktree +- whether setup or maintenance commands can consume injected + `NIGHT_SHIFT_COMPOSE_PROJECT`, `NIGHT_SHIFT_PORT_BASE`, and + `NIGHT_SHIFT_RUNTIME_MANIFEST` values without extra operator wiring Use small tasks that validate the requested behavior instead of inviting large feature work. diff --git a/docs/getting-started.md b/docs/getting-started.md index 20b1dd0..91a812a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -114,6 +114,13 @@ Before it starts, Night Shift checks that the source repository is clean apart from changes inside `./.night-shift/`. That guard exists so worktree execution and delivery stay aligned with the source checkout. +When a task worktree is prepared, Night Shift also generates deterministic +runtime artifacts under the run directory and injects stable `NIGHT_SHIFT_*` +variables into setup, maintenance, provider execution, and verification. The +zero-config defaults are usually enough; add `runtime.named_ports` in +`worktree-setup.toml` only when you want friendly aliases like +`NIGHT_SHIFT_PORT_WEB`. + ## Inspect Results Use these commands while a run is active or after it finishes: @@ -124,8 +131,9 @@ night-shift report ``` `status` prints the current run state, planning and execution agent summaries, -notes source, event count, and report location. `report` prints the current -markdown report directly. +notes source, event count, runtime identity counts, and report location. +`report` prints the current markdown report directly, including per-task +runtime manifest and handoff paths once worktrees have been prepared. ## Supporting Flows diff --git a/docs/state-and-artifacts.md b/docs/state-and-artifacts.md index 6418fee..b8464df 100644 --- a/docs/state-and-artifacts.md +++ b/docs/state-and-artifacts.md @@ -34,6 +34,7 @@ Each run directory contains durable state for one run: - `events.jsonl` - `report.md` - `logs/` +- `runtime/` - `worktrees/` Night Shift only treats a run directory as real once `state.json`, @@ -50,9 +51,27 @@ The run record itself stores: - mechanically derived supersession lineage on replacement tasks - recorded decisions - `planning_dirty` +- persisted per-task `runtime_context` - task list and task states - timestamps and current run status +## Runtime Artifacts + +Night Shift writes per-task runtime artifacts under: + +- `./.night-shift/runs//runtime//night-shift.env` +- `./.night-shift/runs//runtime//night-shift.runtime.json` +- `./.night-shift/runs//runtime//night-shift.handoff.md` + +Those files are generated before setup or maintenance commands run. They live +under the run directory instead of inside the git worktree so they do not +pollute branches or pull requests. + +The persisted task `runtime_context` points at those artifact paths and stores +the deterministic runtime identity Night Shift derived for the task, including +the compose-safe name and port base. Resume and maintenance reuse the saved +context rather than recomputing from current repo config. + ## Planning Artifacts Planning writes artifacts under `./.night-shift/planning//`. Those @@ -78,6 +97,7 @@ vanishing into the terminal scrollback. recovered provider payload - payload-repair attempt, success, and failure notes when Night Shift retried a malformed execution result in place +- runtime identity summaries and artifact paths for prepared tasks - task summaries - planning validation failures - event timeline diff --git a/docs/worktree-environments.md b/docs/worktree-environments.md index 054069b..79756a3 100644 --- a/docs/worktree-environments.md +++ b/docs/worktree-environments.md @@ -22,6 +22,10 @@ default_environment = "default" [environments.default.env] +[environments.default.runtime] +# Optional aliases for deterministic derived ports. +# named_ports = ["web", "api"] + [environments.default.preflight] default = [] macos = [] @@ -45,6 +49,8 @@ Each environment can define: - `env`: environment variables injected into setup, maintenance, provider execution, and verification commands +- `runtime`: optional runtime aliases. v0 supports `named_ports = ["web", + "api"]` and nothing else - `preflight`: commands that validate the environment before work starts - `setup`: commands run when Night Shift creates a task worktree - `maintenance`: commands run when Night Shift reattaches to an existing @@ -53,6 +59,62 @@ Each environment can define: Commands are grouped by platform key: `default`, `macos`, `linux`, and `windows`. +Runtime identity is always enabled, even when `runtime` is omitted. The +runtime subsection only adds friendly named port aliases on top of the default +generated values. + +## Runtime Identity + +Before Night Shift runs environment setup or provider execution for a task, it +derives a stable per-task runtime identity from the run ID and task ID. That +identity is persisted in the run journal and reused on resume, so an existing +task does not silently pick up new runtime values after a config edit. + +Night Shift always injects: + +- `NIGHT_SHIFT_WORKTREE_ID` +- `NIGHT_SHIFT_COMPOSE_PROJECT` +- `NIGHT_SHIFT_PORT_BASE` +- `NIGHT_SHIFT_RUNTIME_DIR` +- `NIGHT_SHIFT_RUNTIME_ENV_FILE` +- `NIGHT_SHIFT_RUNTIME_MANIFEST` +- `NIGHT_SHIFT_HANDOFF_FILE` + +If `named_ports` is configured, Night Shift also injects one variable per +normalized alias: + +- `NIGHT_SHIFT_PORT_WEB` +- `NIGHT_SHIFT_PORT_API` +- and so on + +The generated values are meant to be consumed by your existing setup scripts, +Compose files, and verification commands. Night Shift does not reserve ports, +start services, or manage secrets. + +## Validation Rules + +Night Shift rejects invalid worktree setup config before any task worktrees +launch. + +- `env` may not define variables with the reserved `NIGHT_SHIFT_` prefix +- `runtime.named_ports` entries must normalize to unique uppercase identifiers +- empty names are rejected +- names that normalize to duplicates are rejected +- more than 16 named ports are rejected + +## Generated Artifacts + +Each prepared task gets runtime artifacts under the run directory, not inside +the git worktree: + +- `./.night-shift/runs//runtime//night-shift.env` +- `./.night-shift/runs//runtime//night-shift.runtime.json` +- `./.night-shift/runs//runtime//night-shift.handoff.md` + +`night-shift.env` is plain `KEY=VALUE` output for shell scripts and Compose. +`night-shift.runtime.json` is the machine-readable source of truth. +`night-shift.handoff.md` is the short human-and-agent summary. + ## Selection Rules Environment selection is intentionally conservative: @@ -81,3 +143,6 @@ it into the repository-local Night Shift home. - Put long-lived repo assumptions here rather than inside provider prompts. - Treat `preflight` as the place to fail fast when the environment is not usable. +- Prefer consuming `NIGHT_SHIFT_RUNTIME_ENV_FILE` or + `NIGHT_SHIFT_RUNTIME_MANIFEST` from scripts instead of re-deriving port or + naming schemes yourself. diff --git a/src/night_shift/codec/journal.gleam b/src/night_shift/codec/journal.gleam index ce5bdb1..8cb4c12 100644 --- a/src/night_shift/codec/journal.gleam +++ b/src/night_shift/codec/journal.gleam @@ -190,6 +190,30 @@ fn encode_task(task: types.Task) -> json.Json { #("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: encode_runtime_context), + ), + ]) +} + +fn encode_runtime_context(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)), + #("named_ports", json.array(context.named_ports, encode_runtime_port)), + #("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 encode_runtime_port(port: types.RuntimePort) -> json.Json { + json.object([ + #("name", json.string(port.name)), + #("value", json.int(port.value)), ]) } @@ -413,6 +437,11 @@ fn task_decoder() -> decode.Decoder(types.Task) { use branch_name <- decode.field("branch_name", decode.string) use pr_number <- decode.field("pr_number", decode.string) use summary <- decode.field("summary", decode.string) + use runtime_context <- decode.optional_field( + "runtime_context", + None, + decode.optional(runtime_context_decoder()), + ) decode.success(types.Task( id: id, title: title, @@ -429,9 +458,40 @@ fn task_decoder() -> decode.Decoder(types.Task) { branch_name: branch_name, pr_number: pr_number, summary: summary, + runtime_context: runtime_context, )) } +fn runtime_context_decoder() -> decode.Decoder(types.RuntimeContext) { + use worktree_id <- decode.field("worktree_id", decode.string) + use compose_project <- decode.field("compose_project", decode.string) + use port_base <- decode.field("port_base", decode.int) + use named_ports <- decode.field( + "named_ports", + decode.list(runtime_port_decoder()), + ) + use runtime_dir <- decode.field("runtime_dir", decode.string) + use env_file_path <- decode.field("env_file_path", decode.string) + use manifest_path <- decode.field("manifest_path", decode.string) + use handoff_path <- decode.field("handoff_path", decode.string) + decode.success(types.RuntimeContext( + worktree_id: worktree_id, + compose_project: compose_project, + port_base: port_base, + named_ports: named_ports, + runtime_dir: runtime_dir, + env_file_path: env_file_path, + manifest_path: manifest_path, + handoff_path: handoff_path, + )) +} + +fn runtime_port_decoder() -> decode.Decoder(types.RuntimePort) { + use name <- decode.field("name", decode.string) + use value <- decode.field("value", decode.int) + decode.success(types.RuntimePort(name: name, value: value)) +} + fn decision_request_decoder() -> decode.Decoder(types.DecisionRequest) { use key <- decode.field("key", decode.string) use question <- decode.field("question", decode.string) diff --git a/src/night_shift/codec/provider_payload.gleam b/src/night_shift/codec/provider_payload.gleam index e8fcb28..df3b111 100644 --- a/src/night_shift/codec/provider_payload.gleam +++ b/src/night_shift/codec/provider_payload.gleam @@ -403,6 +403,7 @@ fn planned_task_decoder() -> decode.Decoder(types.Task) { branch_name: "", pr_number: "", summary: "", + runtime_context: None, )) } diff --git a/src/night_shift/codec/worktree_setup.gleam b/src/night_shift/codec/worktree_setup.gleam index a35e517..11bc367 100644 --- a/src/night_shift/codec/worktree_setup.gleam +++ b/src/night_shift/codec/worktree_setup.gleam @@ -4,12 +4,14 @@ import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import night_shift/codec/shared +import night_shift/runtime_identity import night_shift/worktree_setup_model as model import simplifile type Section { RootSection EnvSection(name: String) + RuntimeSection(name: String) PreflightSection(name: String) SetupSection(name: String) MaintenanceSection(name: String) @@ -20,7 +22,33 @@ type ParseState { } pub fn default_template() -> String { - render(model.default_config()) + "version = 1\n" + <> "default_environment = \"default\"\n" + <> "\n" + <> "[environments.default.env]\n" + <> "\n" + <> "# Optional runtime aliases for deterministic per-worktree ports.\n" + <> "# Night Shift still generates runtime identity automatically when this section is absent.\n" + <> "# [environments.default.runtime]\n" + <> "# named_ports = [\"web\", \"api\"]\n" + <> "\n" + <> "[environments.default.preflight]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + <> "\n" + <> "[environments.default.setup]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + <> "\n" + <> "[environments.default.maintenance]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" } pub fn load(path: String) -> Result(Option(model.WorktreeSetupConfig), String) { @@ -173,6 +201,7 @@ fn parse_section(section: String) -> Result(Section, String) { case string.split(inner, ".") { ["environments", name, "env"] -> Ok(EnvSection(name)) + ["environments", name, "runtime"] -> Ok(RuntimeSection(name)) ["environments", name, "preflight"] -> Ok(PreflightSection(name)) ["environments", name, "setup"] -> Ok(SetupSection(name)) ["environments", name, "maintenance"] -> Ok(MaintenanceSection(name)) @@ -217,19 +246,43 @@ fn apply_value( )) EnvSection(name), env_key -> + case string.starts_with(env_key, "NIGHT_SHIFT_") { + True -> + Error( + "Environment variable " + <> env_key + <> " uses the reserved NIGHT_SHIFT_ prefix.", + ) + False -> + Ok(ParseState( + update_environment(config, name, fn(environment) { + model.WorktreeEnvironment( + ..environment, + env_vars: upsert_env_var( + environment.env_vars, + env_key, + shared.parse_string(raw_value), + ), + ) + }), + state.section, + )) + } + + RuntimeSection(name), "named_ports" -> { + use named_ports <- result.try( + validate_named_ports(shared.parse_string_list(raw_value)), + ) Ok(ParseState( update_environment(config, name, fn(environment) { model.WorktreeEnvironment( ..environment, - env_vars: upsert_env_var( - environment.env_vars, - env_key, - shared.parse_string(raw_value), - ), + runtime: model.RuntimeConfig(named_ports: named_ports), ) }), state.section, )) + } PreflightSection(name), script_key -> update_command_set( @@ -336,6 +389,7 @@ fn blank_environment(name: String) -> model.WorktreeEnvironment { model.WorktreeEnvironment( name: name, env_vars: [], + runtime: model.empty_runtime_config(), preflight: model.empty_command_set(), setup: model.empty_command_set(), maintenance: model.empty_command_set(), @@ -365,7 +419,19 @@ fn render_environment(environment: model.WorktreeEnvironment) -> String { <> "\n\n" } + let runtime_block = case environment.runtime.named_ports { + [] -> "" + named_ports -> + "[environments." + <> environment.name + <> ".runtime]\n" + <> "named_ports = " + <> shared.render_string_list(named_ports) + <> "\n\n" + } + env_block + <> runtime_block <> "[environments." <> environment.name <> ".preflight]\n" @@ -399,3 +465,35 @@ fn render_command_set(command_set: model.CommandSet) -> String { fn render_string_list(values: List(String)) -> String { shared.render_string_list(values) } + +fn validate_named_ports(values: List(String)) -> Result(List(String), String) { + case list.length(values) > 16 { + True -> Error("Runtime named_ports may contain at most 16 entries.") + False -> validate_named_ports_loop(values, [], []) + } +} + +fn validate_named_ports_loop( + values: List(String), + normalized_seen: List(String), + acc: List(String), +) -> Result(List(String), String) { + case values { + [] -> Ok(list.reverse(acc)) + [value, ..rest] -> { + use normalized <- result.try(runtime_identity.normalize_port_name(value)) + case list.contains(normalized_seen, normalized) { + True -> + Error( + "Runtime named_ports must be unique after normalization: " + <> normalized, + ) + False -> + validate_named_ports_loop(rest, [normalized, ..normalized_seen], [ + normalized, + ..acc + ]) + } + } + } +} diff --git a/src/night_shift/demo.gleam b/src/night_shift/demo.gleam index e106252..dd1fb79 100644 --- a/src/night_shift/demo.gleam +++ b/src/night_shift/demo.gleam @@ -84,11 +84,11 @@ fn run_headless_demo( "Headless demo failed while running `start`.", )) - use _status_output <- result.try(run_cli_command( - ["status"], + use _status_output <- result.try(wait_for_completed_status( repo_root, filepath.join(demo_root, "headless-status.log"), - "Headless demo failed while running `status`.", + 20, + "Headless demo failed while waiting for `status` to show a completed run.", )) use report_output <- result.try(run_cli_command( @@ -163,11 +163,11 @@ fn run_ui_demo(repo_root: String, demo_root: String) -> Result(String, String) { )) let status_output = - run_cli_command( - ["status"], + wait_for_completed_status( repo_root, filepath.join(demo_root, "ui-status.log"), - "UI demo failed while running `status` after dashboard validation.", + 20, + "UI demo failed while waiting for `status` to show a completed run after dashboard validation.", ) case status_output { @@ -351,6 +351,49 @@ fn wait_for_ui_details( } } +fn wait_for_completed_status( + repo_root: String, + log_path: String, + attempts: Int, + error_message: String, +) -> Result(String, String) { + let status_output = + run_cli_command(["status"], repo_root, log_path, error_message) + + case status_output { + Ok(output) -> + case string.contains(does: output, contain: " is completed") { + True -> Ok(output) + False -> + case attempts <= 0 { + True -> Error(error_message) + False -> { + system.sleep(150) + wait_for_completed_status( + repo_root, + log_path, + attempts - 1, + error_message, + ) + } + } + } + Error(message) -> + case attempts <= 0 { + True -> Error(message) + False -> { + system.sleep(150) + wait_for_completed_status( + repo_root, + log_path, + attempts - 1, + error_message, + ) + } + } + } +} + fn wait_for_completed_dashboard_payload( url: String, run_id: String, diff --git a/src/night_shift/domain/pull_request.gleam b/src/night_shift/domain/pull_request.gleam index 356e458..e1a1d87 100644 --- a/src/night_shift/domain/pull_request.gleam +++ b/src/night_shift/domain/pull_request.gleam @@ -1,5 +1,6 @@ import gleam/int import gleam/list +import gleam/option.{None} import gleam/string import night_shift/types @@ -66,6 +67,7 @@ pub fn review_task( branch_name: head_ref_name, pr_number: int.to_string(number), summary: "", + runtime_context: None, ) } diff --git a/src/night_shift/domain/report.gleam b/src/night_shift/domain/report.gleam index 8b0b731..d9c72f1 100644 --- a/src/night_shift/domain/report.gleam +++ b/src/night_shift/domain/report.gleam @@ -178,6 +178,10 @@ fn render_summary( tasks |> list.filter(fn(task) { task.worktree_path != "" }) |> list.length + let runtime_identity_count = + tasks + |> list.filter(fn(task) { task.runtime_context != None }) + |> list.length [ "- Completed tasks: " <> int.to_string(completed_count), @@ -189,6 +193,7 @@ fn render_summary( "- Failed tasks: " <> int.to_string(failed_count), "- Queued tasks: " <> int.to_string(queued_count), "- Retained worktrees: " <> int.to_string(retained_worktrees), + "- Runtime identities: " <> int.to_string(runtime_identity_count), "- Pruned superseded worktrees: " <> int.to_string(event_count(events, "worktree_pruned")), "- Execution recovery warnings: " @@ -394,8 +399,22 @@ fn render_task_details( pr_numbers -> "\n Supersedes: " <> render_pr_numbers(pr_numbers) } + let runtime_fragment = case task.runtime_context { + None -> "" + Some(context) -> + "\n Runtime: " + <> context.compose_project + <> " | base " + <> int.to_string(context.port_base) + <> "\n Runtime manifest: " + <> context.manifest_path + <> "\n Runtime handoff: " + <> context.handoff_path + } + pr_fragment <> lineage_fragment + <> runtime_fragment <> decision_fragment <> planning_fragment <> summary_fragment diff --git a/src/night_shift/domain/status.gleam b/src/night_shift/domain/status.gleam index b368f9e..5c9ab34 100644 --- a/src/night_shift/domain/status.gleam +++ b/src/night_shift/domain/status.gleam @@ -60,6 +60,9 @@ pub fn summary( <> "Retained worktrees: " <> int.to_string(retained_worktree_count(run.tasks)) <> "\n" + <> "Runtime identities: " + <> int.to_string(runtime_identity_count(run.tasks)) + <> "\n" <> "Pruned superseded worktrees: " <> int.to_string(event_count(events, "worktree_pruned")) <> "\n" @@ -99,6 +102,9 @@ pub fn summary( <> "Retained worktrees: " <> int.to_string(retained_worktree_count(run.tasks)) <> "\n" + <> "Runtime identities: " + <> int.to_string(runtime_identity_count(run.tasks)) + <> "\n" <> "Pruned superseded worktrees: " <> int.to_string(event_count(events, "worktree_pruned")) <> "\n" @@ -180,6 +186,12 @@ fn retained_worktree_count(tasks: List(types.Task)) -> Int { |> list.length } +fn runtime_identity_count(tasks: List(types.Task)) -> Int { + tasks + |> list.filter(fn(task) { task.runtime_context != None }) + |> list.length +} + fn bool_label(value: Bool) -> String { case value { True -> "yes" diff --git a/src/night_shift/domain/task_graph.gleam b/src/night_shift/domain/task_graph.gleam index 1bf1d60..3c7ddff 100644 --- a/src/night_shift/domain/task_graph.gleam +++ b/src/night_shift/domain/task_graph.gleam @@ -1,4 +1,5 @@ import gleam/list +import gleam/option.{None} import gleam/result import gleam/string import night_shift/types @@ -84,6 +85,7 @@ pub fn merge_follow_up_tasks( branch_name: "", pr_number: "", summary: "", + runtime_context: None, ), ..acc ] diff --git a/src/night_shift/infra/task_verifier.gleam b/src/night_shift/infra/task_verifier.gleam index b99585d..072183e 100644 --- a/src/night_shift/infra/task_verifier.gleam +++ b/src/night_shift/infra/task_verifier.gleam @@ -27,6 +27,7 @@ pub fn verify_completed_task( run.repo_root, run.environment_name, project.worktree_setup_path(run.repo_root), + task_run.task.runtime_context, )) case diff --git a/src/night_shift/orchestrator/execution_phase.gleam b/src/night_shift/orchestrator/execution_phase.gleam index 028f053..54820c1 100644 --- a/src/night_shift/orchestrator/execution_phase.gleam +++ b/src/night_shift/orchestrator/execution_phase.gleam @@ -15,6 +15,7 @@ import night_shift/infra/task_verifier import night_shift/journal import night_shift/project import night_shift/provider +import night_shift/runtime_identity import night_shift/system import night_shift/types import night_shift/worktree_setup @@ -148,9 +149,13 @@ fn launch_batch_loop( ) { Ok(#(worktree_path, worktree_origin)) -> { + use task_with_runtime <- result.try(ensure_runtime_context( + run, + task, + )) let running_task = types.Task( - ..task, + ..task_with_runtime, state: types.Running, worktree_path: worktree_path, branch_name: branch_name, @@ -535,6 +540,13 @@ fn start_task_run( env_log: String, worktree_origin: provider.WorktreeOrigin, ) -> Result(#(types.RunRecord, provider.TaskRun), String) { + use runtime_context <- result.try(require_runtime_context(task)) + use _ <- result.try(runtime_identity.ensure_artifacts( + runtime_context, + task, + worktree_path, + branch_name, + )) use _ <- result.try(worktree_setup.prepare_worktree( run.repo_root, run.environment_name, @@ -543,11 +555,13 @@ fn start_task_run( branch_name, bootstrap_phase, env_log, + Some(runtime_context), )) use env_vars <- result.try(worktree_setup.env_vars_for( run.repo_root, run.environment_name, project.worktree_setup_path(run.repo_root), + Some(runtime_context), )) use start_head <- result.try(git.head_commit(worktree_path, git_log)) use task_run <- result.try(provider.start_task( @@ -565,6 +579,61 @@ fn start_task_run( Ok(#(run, task_run)) } +fn ensure_runtime_context( + run: types.RunRecord, + task: types.Task, +) -> Result(types.Task, String) { + case task.runtime_context { + Some(_) -> Ok(task) + None -> { + use named_ports <- result.try(runtime_named_ports( + run.repo_root, + run.environment_name, + )) + use context <- result.try(runtime_identity.build_context( + run.run_path, + run.run_id, + task.id, + task.title, + named_ports, + )) + Ok(types.Task(..task, runtime_context: Some(context))) + } + } +} + +fn runtime_named_ports( + repo_root: String, + environment_name: String, +) -> Result(List(String), String) { + let setup_path = project.worktree_setup_path(repo_root) + use maybe_config <- result.try(worktree_setup.load(setup_path)) + use selected <- result.try( + worktree_setup.choose_environment(maybe_config, case environment_name { + "" -> None + name -> Some(name) + }), + ) + case selected { + Some(environment) -> Ok(environment.runtime.named_ports) + None -> Ok([]) + } +} + +fn require_runtime_context( + task: types.Task, +) -> Result(types.RuntimeContext, String) { + case task.runtime_context { + Some(context) -> Ok(context) + None -> + Error( + "Runtime identity was missing for task " + <> task.id + <> " after worktree preparation.", + ) + } +} + fn mark_task_with_event( run: types.RunRecord, task: types.Task, @@ -670,6 +739,7 @@ fn attempt_payload_repair( run.repo_root, run.environment_name, project.worktree_setup_path(run.repo_root), + task_run.task.runtime_context, ) { Ok(env_vars) -> diff --git a/src/night_shift/project.gleam b/src/night_shift/project.gleam index 2458a0a..9ddc1a9 100644 --- a/src/night_shift/project.gleam +++ b/src/night_shift/project.gleam @@ -28,6 +28,16 @@ pub fn runs_root(repo_root: String) -> String { filepath.join(home(repo_root), "runs") } +/// Return the directory that stores generated runtime identity artifacts. +pub fn runtime_root(run_path: String) -> String { + filepath.join(run_path, "runtime") +} + +/// Return the runtime identity directory for one task. +pub fn task_runtime_root(run_path: String, task_id: String) -> String { + filepath.join(runtime_root(run_path), task_id) +} + /// Return the directory that stores planning artifacts. pub fn planning_root(repo_root: String) -> String { filepath.join(home(repo_root), "planning") diff --git a/src/night_shift/provider_prompt.gleam b/src/night_shift/provider_prompt.gleam index 0203617..25ae19b 100644 --- a/src/night_shift/provider_prompt.gleam +++ b/src/night_shift/provider_prompt.gleam @@ -154,6 +154,8 @@ pub fn execution_prompt(task: types.Task) -> String { <> "Implement the task in the current git worktree.\n" <> "Run your own validation before responding.\n" <> "Do not exceed the task scope.\n" + <> "Night Shift already prepared runtime identity artifacts for this worktree. Reuse them instead of inventing ad hoc ports or service names.\n" + <> "If needed, inspect `NIGHT_SHIFT_RUNTIME_MANIFEST` or `NIGHT_SHIFT_HANDOFF_FILE` for the current runtime contract.\n" <> "Return only one JSON object between the exact sentinel markers below.\n" <> "The content between the markers must be exactly one valid JSON object with no trailing braces, notes, or extra text.\n" <> "Do not include shell transcripts, markdown fences, or explanatory prose inside the JSON payload.\n" @@ -259,6 +261,41 @@ fn render_task(task: types.Task) -> String { <> render_decision_requests(task.decision_requests) <> "\n- Supersedes:\n" <> render_superseded_pr_numbers(task.superseded_pr_numbers) + <> render_runtime_context(task.runtime_context) +} + +fn render_runtime_context(context: Option(types.RuntimeContext)) -> String { + case context { + None -> "\n- Runtime identity:\n - not prepared yet" + Some(runtime) -> + "\n- Runtime identity:\n" + <> " - Worktree ID: " + <> runtime.worktree_id + <> "\n - Compose project: " + <> runtime.compose_project + <> "\n - Port base: " + <> int.to_string(runtime.port_base) + <> "\n - Manifest: " + <> runtime.manifest_path + <> "\n - Handoff: " + <> runtime.handoff_path + <> render_runtime_ports(runtime.named_ports) + } +} + +fn render_runtime_ports(named_ports: List(types.RuntimePort)) -> String { + case named_ports { + [] -> "\n - Named ports: none" + _ -> + "\n - Named ports:\n" + <> { + named_ports + |> list.map(fn(port) { + " - " <> port.name <> ": " <> int.to_string(port.value) + }) + |> string.join(with: "\n") + } + } } fn render_lines(lines: List(String)) -> String { diff --git a/src/night_shift/runtime_identity.gleam b/src/night_shift/runtime_identity.gleam new file mode 100644 index 0000000..3c96e18 --- /dev/null +++ b/src/night_shift/runtime_identity.gleam @@ -0,0 +1,253 @@ +import filepath +import gleam/int +import gleam/json +import gleam/list +import gleam/result +import gleam/string +import night_shift/project +import night_shift/types +import simplifile + +@external(erlang, "night_shift_runtime_identity_ffi", "sha256_hex") +fn sha256_hex(value: String) -> String + +@external(erlang, "night_shift_runtime_identity_ffi", "sha256_mod") +fn sha256_mod(value: String, modulus: Int) -> Int + +@external(erlang, "night_shift_runtime_identity_ffi", "normalize_port_name") +fn normalize_port_name_ffi(value: String) -> String + +@external(erlang, "night_shift_runtime_identity_ffi", "sanitize_task_slug") +fn sanitize_task_slug(value: String) -> String + +pub fn normalize_port_name(value: String) -> Result(String, String) { + let normalized = normalize_port_name_ffi(value) + case normalized { + "" -> + Error( + "Runtime named_ports entries must normalize to an identifier like web or api_port.", + ) + _ -> Ok(normalized) + } +} + +pub fn build_context( + run_path: String, + run_id: String, + task_id: String, + task_title: String, + named_port_names: List(String), +) -> Result(types.RuntimeContext, String) { + use named_ports <- result.try(build_named_ports( + run_id, + task_id, + named_port_names, + )) + let runtime_dir = project.task_runtime_root(run_path, task_id) + let digest = sha256_hex(run_id <> ":" <> task_id) + let hash_prefix = string.drop_end(digest, string.length(digest) - 8) + let worktree_id = sanitize_task_slug(task_title) <> "-" <> hash_prefix + let compose_project = "ns-" <> worktree_id + let port_base = 40_000 + { sha256_mod(run_id <> ":" <> task_id, 1000) * 20 } + + Ok(types.RuntimeContext( + worktree_id: worktree_id, + compose_project: compose_project, + port_base: port_base, + named_ports: assign_port_values(port_base, named_ports, 0, []), + runtime_dir: runtime_dir, + env_file_path: filepath.join(runtime_dir, "night-shift.env"), + manifest_path: filepath.join(runtime_dir, "night-shift.runtime.json"), + handoff_path: filepath.join(runtime_dir, "night-shift.handoff.md"), + )) +} + +pub fn env_vars(context: types.RuntimeContext) -> List(#(String, String)) { + let fixed = [ + #("NIGHT_SHIFT_WORKTREE_ID", context.worktree_id), + #("NIGHT_SHIFT_COMPOSE_PROJECT", context.compose_project), + #("NIGHT_SHIFT_PORT_BASE", int.to_string(context.port_base)), + #("NIGHT_SHIFT_RUNTIME_DIR", context.runtime_dir), + #("NIGHT_SHIFT_RUNTIME_ENV_FILE", context.env_file_path), + #("NIGHT_SHIFT_RUNTIME_MANIFEST", context.manifest_path), + #("NIGHT_SHIFT_HANDOFF_FILE", context.handoff_path), + ] + + list.append( + fixed, + context.named_ports + |> list.map(fn(port) { + #( + "NIGHT_SHIFT_PORT_" <> string.uppercase(port.name), + int.to_string(port.value), + ) + }), + ) +} + +pub fn ensure_artifacts( + context: types.RuntimeContext, + task: types.Task, + worktree_path: String, + branch_name: String, +) -> Result(Nil, String) { + use _ <- result.try( + simplifile.create_directory_all(context.runtime_dir) + |> result.map_error(describe_write_error( + "create runtime identity directory", + )), + ) + use _ <- result.try(write_file( + context.env_file_path, + render_env_file(context), + )) + use _ <- result.try(write_file( + context.manifest_path, + render_manifest(context, task, worktree_path, branch_name), + )) + write_file( + context.handoff_path, + render_handoff(context, task, worktree_path, branch_name), + ) +} + +pub fn summary(context: types.RuntimeContext) -> String { + let ports = case context.named_ports { + [] -> "base " <> int.to_string(context.port_base) + named_ports -> + named_ports + |> list.map(fn(port) { port.name <> "=" <> int.to_string(port.value) }) + |> string.join(with: ", ") + } + + "ID: " + <> context.worktree_id + <> " | Compose: " + <> context.compose_project + <> " | Ports: " + <> ports +} + +fn build_named_ports( + run_id: String, + task_id: String, + named_port_names: List(String), +) -> Result(List(types.RuntimePort), String) { + let _ = run_id + let _ = task_id + Ok( + named_port_names + |> list.map(fn(name) { types.RuntimePort(name: name, value: 0) }), + ) +} + +fn assign_port_values( + port_base: Int, + named_ports: List(types.RuntimePort), + offset: Int, + acc: List(types.RuntimePort), +) -> List(types.RuntimePort) { + case named_ports { + [] -> list.reverse(acc) + [port, ..rest] -> + assign_port_values(port_base, rest, offset + 1, [ + types.RuntimePort(..port, value: port_base + offset), + ..acc + ]) + } +} + +fn render_env_file(context: types.RuntimeContext) -> String { + env_vars(context) + |> list.map(fn(entry) { entry.0 <> "=" <> entry.1 }) + |> string.join(with: "\n") + |> append_newline +} + +fn render_manifest( + context: types.RuntimeContext, + task: types.Task, + worktree_path: String, + branch_name: String, +) -> String { + json.object([ + #("task_id", json.string(task.id)), + #("task_title", json.string(task.title)), + #("branch_name", json.string(branch_name)), + #("worktree_path", json.string(worktree_path)), + #("worktree_id", json.string(context.worktree_id)), + #("compose_project", json.string(context.compose_project)), + #("port_base", json.int(context.port_base)), + #( + "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)), + ]) + |> json.to_string +} + +fn render_handoff( + context: types.RuntimeContext, + task: types.Task, + worktree_path: String, + branch_name: String, +) -> String { + [ + "# Night Shift Runtime Handoff", + "", + "- Task: " <> task.title <> " (`" <> task.id <> "`)", + "- Worktree: " <> worktree_path, + "- Branch: " <> branch_name, + "- Runtime ID: " <> context.worktree_id, + "- Compose project: " <> context.compose_project, + "- Port base: " <> int.to_string(context.port_base), + "- Env file: " <> context.env_file_path, + "- Manifest: " <> context.manifest_path, + "- Handoff: " <> context.handoff_path, + render_port_section(context.named_ports), + ] + |> string.join(with: "\n") + |> append_newline +} + +fn render_port_section(named_ports: List(types.RuntimePort)) -> String { + case named_ports { + [] -> "- Named ports: none" + _ -> + "- Named ports:\n" + <> { + named_ports + |> list.map(fn(port) { + " - " <> port.name <> ": " <> int.to_string(port.value) + }) + |> string.join(with: "\n") + } + } +} + +fn append_newline(value: String) -> String { + case string.ends_with(value, "\n") { + True -> value + False -> value <> "\n" + } +} + +fn write_file(path: String, contents: String) -> Result(Nil, String) { + simplifile.write(contents, to: path) + |> result.map_error(describe_write_error("write " <> path)) +} + +fn describe_write_error(action: String) -> fn(simplifile.FileError) -> String { + fn(error) { + "Unable to " <> action <> ": " <> simplifile.describe_error(error) + } +} diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index 2803b47..6c4cc8a 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -314,6 +314,25 @@ pub type FollowUpTask { ) } +/// One named derived port exposed to a task runtime. +pub type RuntimePort { + RuntimePort(name: String, value: Int) +} + +/// Persisted runtime identity for one task worktree. +pub type RuntimeContext { + RuntimeContext( + worktree_id: String, + compose_project: String, + port_base: Int, + named_ports: List(RuntimePort), + runtime_dir: String, + env_file_path: String, + manifest_path: String, + handoff_path: String, + ) +} + /// A scheduled unit of work inside a Night Shift run. pub type Task { Task( @@ -332,6 +351,7 @@ pub type Task { branch_name: String, pr_number: String, summary: String, + runtime_context: Option(RuntimeContext), ) } diff --git a/src/night_shift/worktree_setup.gleam b/src/night_shift/worktree_setup.gleam index dd10ccb..2997b2d 100644 --- a/src/night_shift/worktree_setup.gleam +++ b/src/night_shift/worktree_setup.gleam @@ -2,6 +2,7 @@ import gleam/option.{type Option} import night_shift/codec/worktree_setup as codec +import night_shift/types import night_shift/worktree_setup_model as model import night_shift/worktree_setup_runtime as runtime @@ -17,6 +18,10 @@ pub type CommandSet = pub type WorktreeEnvironment = model.WorktreeEnvironment +/// Optional runtime aliases configured for one environment. +pub type RuntimeConfig = + model.RuntimeConfig + /// Full repo-local worktree setup configuration. pub type WorktreeSetupConfig = model.WorktreeSetupConfig @@ -75,8 +80,9 @@ pub fn env_vars_for( repo_root: String, environment_name: String, setup_path: String, + runtime_context: Option(types.RuntimeContext), ) -> Result(List(#(String, String)), String) { - runtime.env_vars_for(repo_root, environment_name, setup_path) + runtime.env_vars_for(repo_root, environment_name, setup_path, runtime_context) } /// Execute the commands required to prepare a worktree for one phase. @@ -88,6 +94,7 @@ pub fn prepare_worktree( branch_name: String, phase: BootstrapPhase, log_path: String, + runtime_context: Option(types.RuntimeContext), ) -> Result(Nil, String) { runtime.prepare_worktree( repo_root, @@ -97,6 +104,7 @@ pub fn prepare_worktree( branch_name, phase, log_path, + runtime_context, ) } diff --git a/src/night_shift/worktree_setup_model.gleam b/src/night_shift/worktree_setup_model.gleam index 34cf5b2..a7a78e7 100644 --- a/src/night_shift/worktree_setup_model.gleam +++ b/src/night_shift/worktree_setup_model.gleam @@ -12,10 +12,15 @@ pub type CommandSet { ) } +pub type RuntimeConfig { + RuntimeConfig(named_ports: List(String)) +} + pub type WorktreeEnvironment { WorktreeEnvironment( name: String, env_vars: List(#(String, String)), + runtime: RuntimeConfig, preflight: CommandSet, setup: CommandSet, maintenance: CommandSet, @@ -35,6 +40,7 @@ pub fn default_config() -> WorktreeSetupConfig { WorktreeEnvironment( name: "default", env_vars: [], + runtime: empty_runtime_config(), preflight: empty_command_set(), setup: empty_command_set(), maintenance: empty_command_set(), @@ -45,3 +51,7 @@ pub fn default_config() -> WorktreeSetupConfig { pub fn empty_command_set() -> CommandSet { CommandSet(default: [], macos: [], linux: [], windows: []) } + +pub fn empty_runtime_config() -> RuntimeConfig { + RuntimeConfig(named_ports: []) +} diff --git a/src/night_shift/worktree_setup_runtime.gleam b/src/night_shift/worktree_setup_runtime.gleam index 6e3e7b3..58433c6 100644 --- a/src/night_shift/worktree_setup_runtime.gleam +++ b/src/night_shift/worktree_setup_runtime.gleam @@ -4,8 +4,10 @@ import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import night_shift/codec/worktree_setup +import night_shift/runtime_identity import night_shift/shell import night_shift/system +import night_shift/types import night_shift/worktree_setup_model as model import simplifile @@ -35,6 +37,7 @@ pub fn env_vars_for( repo_root: String, environment_name: String, setup_path: String, + runtime_context: Option(types.RuntimeContext), ) -> Result(List(#(String, String)), String) { use selected <- result.try(load_selected_environment( repo_root, @@ -42,8 +45,9 @@ pub fn env_vars_for( setup_path, )) case selected { - Some(environment) -> Ok(environment.env_vars) - None -> Ok([]) + Some(environment) -> + Ok(merge_runtime_env_vars(environment.env_vars, runtime_context)) + None -> Ok(runtime_env_vars(runtime_context)) } } @@ -55,6 +59,7 @@ pub fn prepare_worktree( branch_name: String, phase: model.BootstrapPhase, log_path: String, + runtime_context: Option(types.RuntimeContext), ) -> Result(Nil, String) { use selected <- result.try(load_selected_environment( repo_root, @@ -83,7 +88,7 @@ pub fn prepare_worktree( "worktree=" <> worktree_path, "branch=" <> branch_name, "environment=" <> environment_label, - "env_vars=" <> redacted_env_names(selected), + "env_vars=" <> redacted_env_names(selected, runtime_context), "", ], with: "\n", @@ -100,7 +105,7 @@ pub fn prepare_worktree( run_environment_commands( commands_for_phase(environment, phase), phase_name, - environment.env_vars, + merge_runtime_env_vars(environment.env_vars, runtime_context), worktree_path, log_path, 1, @@ -137,7 +142,7 @@ pub fn preflight_environment( "[environment-preflight]", "repo_root=" <> repo_root, "environment=" <> environment.name, - "env_vars=" <> redacted_env_names(Some(environment)), + "env_vars=" <> redacted_env_names(Some(environment), None), "path=" <> system.get_env("PATH"), "", ], @@ -203,17 +208,37 @@ fn load_selected_environment( } } -fn redacted_env_names(selected: Option(model.WorktreeEnvironment)) -> String { - case selected { - None -> "(none)" - Some(environment) -> - case environment.env_vars { - [] -> "(none)" - env_vars -> - env_vars - |> list.map(fn(entry) { entry.0 }) - |> string.join(with: ", ") - } +fn redacted_env_names( + selected: Option(model.WorktreeEnvironment), + runtime_context: Option(types.RuntimeContext), +) -> String { + let environment_names = case selected { + None -> [] + Some(environment) -> environment.env_vars |> list.map(fn(entry) { entry.0 }) + } + let runtime_names = + runtime_env_vars(runtime_context) + |> list.map(fn(entry) { entry.0 }) + + case list.append(environment_names, runtime_names) { + [] -> "(none)" + names -> string.join(names, with: ", ") + } +} + +fn merge_runtime_env_vars( + env_vars: List(#(String, String)), + runtime_context: Option(types.RuntimeContext), +) -> List(#(String, String)) { + list.append(env_vars, runtime_env_vars(runtime_context)) +} + +fn runtime_env_vars( + runtime_context: Option(types.RuntimeContext), +) -> List(#(String, String)) { + case runtime_context { + Some(context) -> runtime_identity.env_vars(context) + None -> [] } } diff --git a/src/night_shift_runtime_identity_ffi.erl b/src/night_shift_runtime_identity_ffi.erl new file mode 100644 index 0000000..710399c --- /dev/null +++ b/src/night_shift_runtime_identity_ffi.erl @@ -0,0 +1,64 @@ +-module(night_shift_runtime_identity_ffi). + +-export([normalize_port_name/1, sanitize_task_slug/1, sha256_hex/1, sha256_mod/2]). + +sha256_hex(Data) -> + Binary = unicode:characters_to_binary(Data), + Digest = crypto:hash(sha256, Binary), + list_to_binary([io_lib:format("~2.16.0b", [Byte]) || <> <= Digest]). + +sha256_mod(Data, Modulus) when is_integer(Modulus), Modulus > 0 -> + Digest = crypto:hash(sha256, unicode:characters_to_binary(Data)), + binary:decode_unsigned(Digest) rem Modulus. + +normalize_port_name(Value) -> + Normalized = normalize(Value, $_), + case Normalized of + <<>> -> <<>>; + <> when First >= $a, First =< $z -> Normalized; + _ -> <<>> + end. + +sanitize_task_slug(Value) -> + case normalize(Value, $-) of + <<>> -> <<"task">>; + Slug -> Slug + end. + +normalize(Value, Separator) -> + Lowered = string:lowercase(unicode:characters_to_binary(Value)), + trim_separator(collapse_non_alnum(Lowered, Separator, false, <<>>), Separator). + +collapse_non_alnum(<<>>, _Separator, _LastWasSeparator, Acc) -> + Acc; +collapse_non_alnum(<>, Separator, LastWasSeparator, Acc) -> + case is_lower_alnum(Char) of + true -> + collapse_non_alnum(Rest, Separator, false, <>); + false when LastWasSeparator -> + collapse_non_alnum(Rest, Separator, true, Acc); + false -> + collapse_non_alnum(Rest, Separator, true, <>) + end. + +trim_separator(Binary, Separator) -> + trim_leading(trim_trailing(Binary, Separator), Separator). + +trim_leading(<>, Separator) -> + trim_leading(Rest, Separator); +trim_leading(Binary, _Separator) -> + Binary. + +trim_trailing(Binary, Separator) -> + case Binary of + <<>> -> <<>>; + _ -> + Size = byte_size(Binary) - 1, + case Binary of + <> -> trim_trailing(Prefix, Separator); + _ -> Binary + end + end. + +is_lower_alnum(Char) -> + (Char >= $a andalso Char =< $z) orelse (Char >= $0 andalso Char =< $9). diff --git a/test/domain_decision_contract_test.gleam b/test/domain_decision_contract_test.gleam index dfc52cc..71d37d5 100644 --- a/test/domain_decision_contract_test.gleam +++ b/test/domain_decision_contract_test.gleam @@ -38,6 +38,7 @@ pub fn reconcile_decision_requests_reuses_recorded_file_location_answer_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let assert Ok(#([updated_task], warnings)) = @@ -91,6 +92,7 @@ pub fn reconcile_decision_requests_rejects_ambiguous_matches_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let assert Error(message) = diff --git a/test/domain_decision_test.gleam b/test/domain_decision_test.gleam index 2491c27..18f3266 100644 --- a/test/domain_decision_test.gleam +++ b/test/domain_decision_test.gleam @@ -1,5 +1,5 @@ import gleam/list -import gleam/option.{Some} +import gleam/option.{None, Some} import night_shift/domain/decisions import night_shift/types @@ -90,5 +90,6 @@ fn manual_attention_task() -> types.Task { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) } diff --git a/test/domain_plan_hygiene_test.gleam b/test/domain_plan_hygiene_test.gleam index b01ad69..9395c20 100644 --- a/test/domain_plan_hygiene_test.gleam +++ b/test/domain_plan_hygiene_test.gleam @@ -1,4 +1,5 @@ import gleam/list +import gleam/option.{None} import gleam/string import gleeunit/should import night_shift/domain/plan_hygiene @@ -22,6 +23,7 @@ pub fn normalize_planned_tasks_merges_single_validation_tail_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let validation = types.Task( @@ -40,6 +42,7 @@ pub fn normalize_planned_tasks_merges_single_validation_tail_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let assert Ok([merged]) = @@ -68,6 +71,7 @@ pub fn normalize_planned_tasks_rejects_fragmented_tiny_plan_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let implementation = types.Task( @@ -86,6 +90,7 @@ pub fn normalize_planned_tasks_rejects_fragmented_tiny_plan_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let validation = types.Task( @@ -104,6 +109,7 @@ pub fn normalize_planned_tasks_rejects_fragmented_tiny_plan_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) let assert Error(message) = diff --git a/test/domain_pull_request_test.gleam b/test/domain_pull_request_test.gleam index 01dcf2f..5640f85 100644 --- a/test/domain_pull_request_test.gleam +++ b/test/domain_pull_request_test.gleam @@ -145,5 +145,6 @@ fn sample_task() -> types.Task { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) } diff --git a/test/domain_report_test.gleam b/test/domain_report_test.gleam index 59a12c2..9464a66 100644 --- a/test/domain_report_test.gleam +++ b/test/domain_report_test.gleam @@ -139,6 +139,7 @@ fn replacement_task( branch_name: "night-shift/" <> id, pr_number: pr_number, summary: "Updated " <> id, + runtime_context: None, ) } diff --git a/test/domain_review_lineage_test.gleam b/test/domain_review_lineage_test.gleam index 5c29003..65c2fcc 100644 --- a/test/domain_review_lineage_test.gleam +++ b/test/domain_review_lineage_test.gleam @@ -1,4 +1,5 @@ import gleam/int +import gleam/option.{None} import gleam/string import night_shift/domain/repo_state import night_shift/domain/review_lineage @@ -90,5 +91,6 @@ fn task(id: String, dependencies: List(String)) -> types.Task { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) } diff --git a/test/domain_run_state_test.gleam b/test/domain_run_state_test.gleam index 70e0cfa..74150fc 100644 --- a/test/domain_run_state_test.gleam +++ b/test/domain_run_state_test.gleam @@ -1,3 +1,4 @@ +import gleam/option.{None} import night_shift/domain/run_state import night_shift/types @@ -51,5 +52,6 @@ fn task_with_state(id: String, state: types.TaskState) -> types.Task { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) } diff --git a/test/domain_task_graph_test.gleam b/test/domain_task_graph_test.gleam index ad29eb1..e1f6125 100644 --- a/test/domain_task_graph_test.gleam +++ b/test/domain_task_graph_test.gleam @@ -1,4 +1,5 @@ import gleam/list +import gleam/option.{None} import night_shift/domain/task_graph import night_shift/types @@ -20,6 +21,7 @@ pub fn refresh_ready_states_promotes_completed_dependencies_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ), types.Task( id: "verify", @@ -37,6 +39,7 @@ pub fn refresh_ready_states_promotes_completed_dependencies_test() { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ), ] @@ -124,5 +127,6 @@ fn ready_task(id: String, mode: types.ExecutionMode) -> types.Task { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) } diff --git a/test/domain_task_validation_test.gleam b/test/domain_task_validation_test.gleam index 285f06c..7a23333 100644 --- a/test/domain_task_validation_test.gleam +++ b/test/domain_task_validation_test.gleam @@ -1,3 +1,4 @@ +import gleam/option.{None} import night_shift/domain/task_validation import night_shift/types @@ -87,6 +88,7 @@ fn task(id: String, dependencies: List(String)) -> types.Task { branch_name: "", pr_number: "", summary: "", + runtime_context: None, ) } diff --git a/test/night_shift_dashboard_demo_test.gleam b/test/night_shift_dashboard_demo_test.gleam index 84e80e5..edd8dec 100644 --- a/test/night_shift_dashboard_demo_test.gleam +++ b/test/night_shift_dashboard_demo_test.gleam @@ -103,7 +103,7 @@ pub fn dashboard_start_session_tracks_completed_run_test() { 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 final_payload = support.wait_for_run_payload(session.url, run.run_id, 20) + let final_payload = support.wait_for_run_payload(session.url, run.run_id, 40) system.set_env("PATH", old_path) support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 4747eb8..2c23a3b 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -1,4 +1,5 @@ import filepath +import gleam/int import gleam/list import gleam/option.{None, Some} import gleam/result @@ -175,7 +176,7 @@ pub fn orchestrator_start_runs_fake_provider_test() { let completed_task = completed_run.tasks - |> list.find(fn(task) { task.state == types.Completed }) + |> list.find(fn(task) { task.id == "demo-task" }) |> result.unwrap(or: types.Task( id: "missing", title: "missing", @@ -192,6 +193,7 @@ pub fn orchestrator_start_runs_fake_provider_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) assert completed_run.status == types.RunCompleted @@ -294,6 +296,7 @@ pub fn orchestrator_start_preserves_partial_success_after_delivery_failure_test( pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let beta_task = failed_run.tasks @@ -314,6 +317,7 @@ pub fn orchestrator_start_preserves_partial_success_after_delivery_failure_test( pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let assert Ok(report_contents) = simplifile.read(failed_run.report_path) let assert Ok(events) = simplifile.read(failed_run.events_path) @@ -457,6 +461,7 @@ pub fn orchestrator_start_delivers_provider_created_commit_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) assert completed_run.status == types.RunCompleted @@ -516,6 +521,7 @@ pub fn start_task_runs_codex_execution_in_worktree_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -544,6 +550,172 @@ pub fn start_task_runs_codex_execution_in_worktree_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn orchestrator_start_generates_runtime_identity_artifacts_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-runtime-artifacts-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let remote_root = filepath.join(base_dir, "remote.git") + let bin_dir = filepath.join(base_dir, "bin") + let brief_path = filepath.join(base_dir, "brief.md") + let fake_provider = filepath.join(bin_dir, "fake-provider") + let fake_gh = filepath.join(bin_dir, "gh") + let state_home = filepath.join(base_dir, "state") + let old_path = system.get_env("PATH") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let old_state_home = system.get_env("XDG_STATE_HOME") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let _ = + simplifile.delete(file_or_dir_at: journal.repo_state_path_for(repo_root)) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + let assert Ok(_) = support.initialize_project_home(repo_root) + let assert Ok(_) = support.write_fake_provider(fake_provider) + let assert Ok(_) = support.write_fake_gh(fake_gh) + let assert Ok(_) = + support.write_test_worktree_setup_with_runtime( + project.worktree_setup_path(repo_root), + ["web", "api"], + [ + "printenv NIGHT_SHIFT_COMPOSE_PROJECT > runtime-compose-project.txt", + "printenv NIGHT_SHIFT_PORT_WEB > runtime-port-web.txt", + "printenv NIGHT_SHIFT_RUNTIME_MANIFEST > runtime-manifest-path.txt", + ], + [], + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_provider) <> " " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + let _ = + shell.run( + "git init --bare " <> shell.quote(remote_root), + base_dir, + filepath.join(base_dir, "remote.log"), + ) + support.seed_git_repo(repo_root, base_dir) + let _ = + shell.run( + "git remote add origin " <> shell.quote(remote_root), + repo_root, + filepath.join(base_dir, "remote-add.log"), + ) + let _ = + shell.run( + "git push -u origin main", + repo_root, + filepath.join(base_dir, "push-main.log"), + ) + + system.set_env("NIGHT_SHIFT_FAKE_PROVIDER", fake_provider) + 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(run) = + support.planned_run_in_environment( + repo_root, + brief_path, + types.Codex, + "default", + 1, + ) + let assert Ok(completed_run) = orchestrator.start(run, config) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + support.restore_env("XDG_STATE_HOME", old_state_home) + + let completed_task = + completed_run.tasks + |> list.find(fn(task) { task.state == types.Completed }) + |> result.unwrap(or: types.Task( + id: "missing", + title: "missing", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Failed, + worktree_path: "", + branch_name: "", + pr_number: "", + superseded_pr_numbers: [], + summary: "", + runtime_context: None, + )) + assert completed_run.status == types.RunCompleted + let assert Some(runtime_context) = completed_task.runtime_context + let assert Ok(env_contents) = simplifile.read(runtime_context.env_file_path) + let assert Ok(manifest_contents) = + simplifile.read(runtime_context.manifest_path) + let assert Ok(handoff_contents) = + simplifile.read(runtime_context.handoff_path) + let assert Ok(compose_project_contents) = + simplifile.read(filepath.join( + completed_task.worktree_path, + "runtime-compose-project.txt", + )) + let assert Ok(port_web_contents) = + simplifile.read(filepath.join( + completed_task.worktree_path, + "runtime-port-web.txt", + )) + let assert Ok(manifest_path_contents) = + simplifile.read(filepath.join( + completed_task.worktree_path, + "runtime-manifest-path.txt", + )) + + assert string.contains( + does: env_contents, + contain: "NIGHT_SHIFT_COMPOSE_PROJECT=" <> runtime_context.compose_project, + ) + assert string.contains(does: env_contents, contain: "NIGHT_SHIFT_PORT_WEB=") + assert string.contains( + does: compose_project_contents, + contain: runtime_context.compose_project, + ) + assert string.contains( + does: port_web_contents, + contain: int.to_string(runtime_context.port_base), + ) + assert string.contains( + does: manifest_path_contents, + contain: runtime_context.manifest_path, + ) + assert string.contains( + does: manifest_contents, + contain: "\"compose_project\":\"" <> runtime_context.compose_project <> "\"", + ) + assert string.contains(does: handoff_contents, contain: "Compose project") + assert simplifile.read(filepath.join( + completed_task.worktree_path, + "night-shift.runtime.json", + )) + |> result.is_error + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn provider_await_task_recovers_trailing_junk_test() { let unique = system.unique_id() let base_dir = @@ -592,6 +764,7 @@ pub fn provider_await_task_recovers_trailing_junk_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -678,6 +851,7 @@ pub fn provider_await_task_normalizes_absolute_files_touched_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -752,6 +926,7 @@ pub fn provider_await_task_rejects_absolute_paths_outside_worktree_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -826,6 +1001,7 @@ pub fn provider_payload_repair_accepts_valid_repair_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -922,6 +1098,7 @@ pub fn provider_payload_repair_accepts_recoverable_repair_with_warning_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -1018,6 +1195,7 @@ pub fn provider_payload_repair_rejects_unsafe_paths_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, ) let assert Ok(task_run) = @@ -1226,6 +1404,7 @@ pub fn orchestrator_start_prunes_clean_superseded_worktrees_test() { branch_name: "night-shift/prior", pr_number: "12", summary: "Prior completed task", + runtime_context: None, ) let assert Ok(_) = journal.rewrite_run( @@ -1263,6 +1442,7 @@ pub fn orchestrator_start_prunes_clean_superseded_worktrees_test() { branch_name: "night-shift/rewrite-root-v2", pr_number: "15", summary: "Replacement completed task", + runtime_context: None, ) let replacement_run = types.RunRecord(..current_run, status: types.RunActive, tasks: [ @@ -1385,6 +1565,7 @@ pub fn orchestrator_start_blocks_manual_attention_before_bootstrap_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let assert Ok(events) = simplifile.read(blocked_run.events_path) @@ -1667,6 +1848,7 @@ pub fn orchestrator_start_reports_setup_phase_failures_after_preflight_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) assert failed_run.status == types.RunFailed @@ -1858,6 +2040,7 @@ pub fn orchestrator_start_marks_decode_failures_failed_and_clears_lock_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let assert Ok(events) = simplifile.read(failed_run.events_path) let assert Ok(raw_payload) = @@ -2018,6 +2201,7 @@ pub fn orchestrator_start_routes_dirty_decode_failures_to_manual_attention_test( pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let assert Ok(report) = simplifile.read(blocked_run.report_path) let assert Ok(events) = simplifile.read(blocked_run.events_path) @@ -2150,6 +2334,7 @@ pub fn orchestrator_start_recovers_dirty_decode_failures_with_payload_repair_tes pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let assert Ok(events) = simplifile.read(completed_run.events_path) let assert Ok(report) = simplifile.read(completed_run.report_path) @@ -2251,6 +2436,7 @@ pub fn orchestrator_start_blocks_invalid_follow_up_tasks_before_delivery_test() pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let assert Ok(events_text) = simplifile.read(blocked_run.events_path) let assert Ok(created_file) = @@ -2353,6 +2539,7 @@ pub fn orchestrator_start_continues_awaiting_batch_after_decode_failure_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) let fail_task = failed_run.tasks @@ -2373,6 +2560,7 @@ pub fn orchestrator_start_continues_awaiting_batch_after_decode_failure_test() { pr_number: "", superseded_pr_numbers: [], summary: "", + runtime_context: None, )) assert failed_run.status == types.RunFailed diff --git a/test/night_shift_lifecycle_test.gleam b/test/night_shift_lifecycle_test.gleam index 8cadff2..3452e11 100644 --- a/test/night_shift_lifecycle_test.gleam +++ b/test/night_shift_lifecycle_test.gleam @@ -1030,6 +1030,7 @@ pub fn reset_command_removes_project_home_and_worktrees_test() { branch_name: "night-shift/reset-demo", pr_number: "", summary: "", + runtime_context: None, ), ]) let assert Ok(_) = journal.rewrite_run(run_with_worktree) diff --git a/test/night_shift_test_support.gleam b/test/night_shift_test_support.gleam index c5f5d3e..51932b0 100644 --- a/test/night_shift_test_support.gleam +++ b/test/night_shift_test_support.gleam @@ -36,26 +36,11 @@ pub fn initialize_project_home( } pub fn local_demo_command() -> String { - let cwd = system.cwd() - let erlang_root = filepath.join(cwd, "build/dev/erlang") - let ebin_paths = [ - filepath.join(erlang_root, "night_shift/ebin"), - filepath.join(erlang_root, "gleam_stdlib/ebin"), - filepath.join(erlang_root, "gleam_json/ebin"), - filepath.join(erlang_root, "filepath/ebin"), - filepath.join(erlang_root, "simplifile/ebin"), - filepath.join(erlang_root, "gleeunit/ebin"), - ] - - "erl" - <> { - ebin_paths - |> list.map(fn(path) { " -pa " <> shell.quote(path) }) - |> string.join(with: "") - } - <> " -noshell -eval " - <> shell.quote("'night_shift@@main':run(night_shift).") - <> " -extra" + "zsh -lc " + <> shell.quote( + "cd " <> shell.quote(system.cwd()) <> " && gleam run -- \"$@\"", + ) + <> " night-shift" } pub fn script_capture_command(command: String) -> String { @@ -279,9 +264,10 @@ pub fn write_test_worktree_setup( setup_commands: List(String), maintenance_commands: List(String), ) -> Result(Nil, simplifile.FileError) { - write_test_worktree_setup_with_preflight( + write_test_worktree_setup_with_runtime_and_preflight( path, [], + [], setup_commands, maintenance_commands, ) @@ -293,10 +279,51 @@ pub fn write_test_worktree_setup_with_preflight( setup_commands: List(String), maintenance_commands: List(String), ) -> Result(Nil, simplifile.FileError) { + write_test_worktree_setup_with_runtime_and_preflight( + path, + [], + preflight_commands, + setup_commands, + maintenance_commands, + ) +} + +pub fn write_test_worktree_setup_with_runtime( + path: String, + named_ports: List(String), + setup_commands: List(String), + maintenance_commands: List(String), +) -> Result(Nil, simplifile.FileError) { + write_test_worktree_setup_with_runtime_and_preflight( + path, + named_ports, + [], + setup_commands, + maintenance_commands, + ) +} + +pub fn write_test_worktree_setup_with_runtime_and_preflight( + path: String, + named_ports: List(String), + preflight_commands: List(String), + setup_commands: List(String), + maintenance_commands: List(String), +) -> Result(Nil, simplifile.FileError) { + let runtime_section = case named_ports { + [] -> "" + _ -> + "[environments.default.runtime]\n" + <> "named_ports = " + <> render_command_list(named_ports) + <> "\n\n" + } + simplifile.write( "version = 1\n" <> "default_environment = \"default\"\n\n" <> "[environments.default.env]\n\n" + <> runtime_section <> "[environments.default.preflight]\n" <> "default = " <> render_command_list(preflight_commands) diff --git a/test/runtime_identity_test.gleam b/test/runtime_identity_test.gleam new file mode 100644 index 0000000..bd4678c --- /dev/null +++ b/test/runtime_identity_test.gleam @@ -0,0 +1,256 @@ +import filepath +import gleam/int +import gleam/list +import gleam/option.{None} +import gleam/string +import night_shift/runtime_identity +import night_shift/system +import night_shift/types +import night_shift/worktree_setup +import simplifile + +pub fn worktree_setup_parse_defaults_runtime_named_ports_when_absent_test() { + let contents = + "version = 1\n" + <> "default_environment = \"default\"\n\n" + <> "[environments.default.env]\n\n" + <> "[environments.default.preflight]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.setup]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.maintenance]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + + let assert Ok(config) = worktree_setup.parse(contents) + let assert Ok(environment) = + worktree_setup.find_environment(config, "default") + + assert environment.runtime.named_ports == [] +} + +pub fn worktree_setup_parse_accepts_runtime_named_ports_test() { + let contents = + "version = 1\n" + <> "default_environment = \"default\"\n\n" + <> "[environments.default.env]\n\n" + <> "[environments.default.runtime]\n" + <> "named_ports = [\"web\", \"API Port\"]\n\n" + <> "[environments.default.preflight]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.setup]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.maintenance]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + + let assert Ok(config) = worktree_setup.parse(contents) + let assert Ok(environment) = + worktree_setup.find_environment(config, "default") + + assert environment.runtime.named_ports == ["web", "api_port"] +} + +pub fn worktree_setup_parse_rejects_reserved_night_shift_env_var_test() { + let contents = + "version = 1\n" + <> "default_environment = \"default\"\n\n" + <> "[environments.default.env]\n" + <> "NIGHT_SHIFT_PORT_BASE = \"41000\"\n\n" + <> "[environments.default.preflight]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.setup]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.maintenance]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + + let assert Error(message) = worktree_setup.parse(contents) + + assert string.contains(does: message, contain: "reserved NIGHT_SHIFT_ prefix") +} + +pub fn worktree_setup_parse_rejects_duplicate_named_ports_after_normalization_test() { + let contents = + "version = 1\n" + <> "default_environment = \"default\"\n\n" + <> "[environments.default.env]\n\n" + <> "[environments.default.runtime]\n" + <> "named_ports = [\"API Port\", \"api-port\"]\n\n" + <> "[environments.default.preflight]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.setup]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.maintenance]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + + let assert Error(message) = worktree_setup.parse(contents) + + assert string.contains(does: message, contain: "unique after normalization") +} + +pub fn worktree_setup_parse_rejects_too_many_named_ports_test() { + let contents = + "version = 1\n" + <> "default_environment = \"default\"\n\n" + <> "[environments.default.env]\n\n" + <> "[environments.default.runtime]\n" + <> "named_ports = " + <> render_port_list(build_port_names(17, [])) + <> "\n\n" + <> "[environments.default.preflight]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.setup]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n\n" + <> "[environments.default.maintenance]\n" + <> "default = []\n" + <> "macos = []\n" + <> "linux = []\n" + <> "windows = []\n" + + let assert Error(message) = worktree_setup.parse(contents) + + assert string.contains(does: message, contain: "at most 16 entries") +} + +pub fn runtime_identity_build_context_is_deterministic_test() { + let assert Ok(first) = + runtime_identity.build_context( + "/tmp/run", + "run-123", + "demo-task", + "Demo Task", + ["web", "api"], + ) + let assert Ok(second) = + runtime_identity.build_context( + "/tmp/run", + "run-123", + "demo-task", + "Demo Task", + ["web", "api"], + ) + + assert first.worktree_id == second.worktree_id + assert first.compose_project == second.compose_project + assert first.port_base == second.port_base + assert first.named_ports == second.named_ports + assert string.starts_with(first.compose_project, "ns-") +} + +pub fn runtime_identity_ensure_artifacts_writes_env_manifest_and_handoff_test() { + let unique = system.unique_id() + let base_dir = + filepath.join( + system.state_directory(), + "night-shift-runtime-identity-" <> unique, + ) + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(context) = + runtime_identity.build_context( + filepath.join(base_dir, "run"), + "run-456", + "demo-task", + "Demo Task", + ["web"], + ) + let task = + types.Task( + id: "demo-task", + title: "Demo Task", + description: "Demo", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Ready, + worktree_path: "", + branch_name: "", + pr_number: "", + summary: "", + runtime_context: None, + ) + + let assert Ok(_) = + runtime_identity.ensure_artifacts( + context, + task, + filepath.join(base_dir, "worktree"), + "night-shift/demo-task", + ) + let assert Ok(env_contents) = simplifile.read(context.env_file_path) + let assert Ok(manifest_contents) = simplifile.read(context.manifest_path) + let assert Ok(handoff_contents) = simplifile.read(context.handoff_path) + + assert string.contains( + does: env_contents, + contain: "NIGHT_SHIFT_COMPOSE_PROJECT=", + ) + assert string.contains( + does: manifest_contents, + contain: "\"compose_project\"", + ) + assert string.contains(does: handoff_contents, contain: "Compose project") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +fn build_port_names(count: Int, acc: List(String)) -> List(String) { + case count <= 0 { + True -> list.reverse(acc) + False -> + build_port_names(count - 1, ["port" <> int.to_string(count), ..acc]) + } +} + +fn render_port_list(values: List(String)) -> String { + "[" + <> { + values + |> list.map(fn(value) { "\"" <> value <> "\"" }) + |> string.join(with: ", ") + } + <> "]" +} From 42a7119c01b26a2c31771874ca1630462ac3f343 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:29:02 -0700 Subject: [PATCH 04/13] Limit confidence decision counts to manual-attention tasks --- src/night_shift/domain/confidence.gleam | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/night_shift/domain/confidence.gleam b/src/night_shift/domain/confidence.gleam index 5336506..37e2f19 100644 --- a/src/night_shift/domain/confidence.gleam +++ b/src/night_shift/domain/confidence.gleam @@ -185,6 +185,9 @@ fn positive_reasons( fn unresolved_decision_requests_count(run: types.RunRecord) -> Int { run.tasks + |> list.filter(fn(task) { + types.task_requires_manual_attention(run.decisions, task) + }) |> list.map(fn(task) { list.length(types.unresolved_decision_requests(run.decisions, task)) }) From fef671f484388339758a3c312c52c0848fc07167 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:40:51 -0700 Subject: [PATCH 05/13] Fix doctor worktree cleanliness probe --- src/night_shift/usecase/doctor.gleam | 16 +++++-- test/trust_surface_test.gleam | 67 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/night_shift/usecase/doctor.gleam b/src/night_shift/usecase/doctor.gleam index 8842b32..8c02acb 100644 --- a/src/night_shift/usecase/doctor.gleam +++ b/src/night_shift/usecase/doctor.gleam @@ -155,12 +155,19 @@ fn diagnose_task( ], ) types.Running -> - diagnose_running_task(task, execution_log, worktree_exists, mounted_worktree) + diagnose_running_task( + task, + run_path, + execution_log, + worktree_exists, + mounted_worktree, + ) } } fn diagnose_running_task( task: types.Task, + run_path: String, execution_log: String, worktree_exists: Bool, mounted_worktree: Result(Option(String), String), @@ -184,10 +191,12 @@ fn diagnose_running_task( "Recorded worktree path no longer exists on disk.", ], ) - True -> + True -> { + let doctor_git_log = + filepath.join(run_path, "logs/" <> task.id <> ".doctor.has-changes.log") case git.has_changes( task.worktree_path, - filepath.join(task.worktree_path, ".night-shift-doctor.log"), + doctor_git_log, ) { True -> TaskAssessment( @@ -200,6 +209,7 @@ fn diagnose_running_task( False -> diagnose_clean_running_task(task, execution_log, mounted_worktree) } + } } } } diff --git a/test/trust_surface_test.gleam b/test/trust_surface_test.gleam index 2efd170..8b34568 100644 --- a/test/trust_surface_test.gleam +++ b/test/trust_surface_test.gleam @@ -4,9 +4,11 @@ import gleam/string import night_shift/dashboard import night_shift/domain/provenance as provenance_domain import night_shift/domain/repo_state +import night_shift/git import night_shift/journal import night_shift/report import night_shift/repo_state_runtime +import night_shift/shell import night_shift/system import night_shift/types import night_shift/usecase/doctor @@ -168,6 +170,71 @@ pub fn doctor_flags_dirty_and_missing_worktrees_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn doctor_does_not_write_probe_log_into_worktree_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-doctor-clean-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + let worktree_path = filepath.join(base_dir, "clean-worktree") + let probe_path = filepath.join(worktree_path, ".night-shift-doctor.log") + let git_log = filepath.join(base_dir, "worktree-add.log") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = + git.create_worktree( + repo_root, + worktree_path, + "night-shift/clean-task", + "main", + git_log, + ) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let updated_run = + types.RunRecord( + ..run, + tasks: [ + types.Task( + id: "clean-task", + title: "Clean task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: worktree_path, + branch_name: "night-shift/clean-task", + pr_number: "", + summary: "", + ), + ], + ) + let assert Ok(_) = journal.rewrite_run(updated_run) + let assert Ok(rendered) = + doctor.execute(repo_root, types.LatestRun, types.default_config()) + + assert string.contains(does: rendered, contain: "[resume_with_warning] Clean task") + let assert Error(_) = simplifile.read(probe_path) + + let _ = + shell.run( + "git worktree remove --force " <> shell.quote(worktree_path), + repo_root, + filepath.join(base_dir, "worktree-remove.log"), + ) + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + fn review_run() -> types.RunRecord { types.RunRecord( run_id: "review-run", From 5653e1c2d443b9962af5cc81f9d035cf562b2e2b Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:42:48 -0700 Subject: [PATCH 06/13] Fix PR handoff review edge cases --- src/night_shift/domain/pr_handoff.gleam | 19 +++++-- src/night_shift/github.gleam | 31 ++++++++-- test/domain_pr_handoff_test.gleam | 15 +++++ .../night_shift_execution_delivery_test.gleam | 57 +++++++++++++++++++ test/night_shift_test_support.gleam | 20 ++++--- 5 files changed, 126 insertions(+), 16 deletions(-) diff --git a/src/night_shift/domain/pr_handoff.gleam b/src/night_shift/domain/pr_handoff.gleam index 3288719..dd6c171 100644 --- a/src/night_shift/domain/pr_handoff.gleam +++ b/src/night_shift/domain/pr_handoff.gleam @@ -127,11 +127,13 @@ fn render_scope( } let scope_lines = case handoff.include_stack_context { True -> - list.append(scope_lines, [ - "Branch: " <> fallback_scalar(task.branch_name), - "PR: " <> fallback_scalar(task.pr_number), - "Supersedes: " <> render_pr_numbers(task.superseded_pr_numbers), - ]) + list.append( + list.append(scope_lines, [ + "Branch: " <> fallback_scalar(task.branch_name), + "Supersedes: " <> render_pr_numbers(task.superseded_pr_numbers), + ]), + optional_scope_line("PR", task.pr_number), + ) False -> scope_lines } @@ -350,6 +352,13 @@ fn fallback_scalar(value: String) -> String { } } +fn optional_scope_line(label: String, value: String) -> List(String) { + case string.trim(value) { + "" -> [] + trimmed -> [label <> ": " <> trimmed] + } +} + fn render_optional(value: Option(String)) -> String { case value { Some(contents) -> string.trim(contents) diff --git a/src/night_shift/github.gleam b/src/night_shift/github.gleam index aa1a8c1..08d711a 100644 --- a/src/night_shift/github.gleam +++ b/src/night_shift/github.gleam @@ -385,17 +385,40 @@ fn issue_comments( cwd: String, pr_number: Int, log_path: String, +) -> Result(List(IssueComment), String) { + issue_comments_page(cwd, pr_number, 1, log_path, []) +} + +fn issue_comments_page( + cwd: String, + pr_number: Int, + page: Int, + log_path: String, + acc: List(IssueComment), ) -> Result(List(IssueComment), String) { let command = gh_api_command( - "repos/:owner/:repo/issues/" <> int.to_string(pr_number) <> "/comments", + "repos/:owner/:repo/issues/" + <> int.to_string(pr_number) + <> "/comments?page=" + <> int.to_string(page) + <> "&per_page=100", ) let result = shell.run(command, cwd, log_path) case shell.succeeded(result) { - True -> - json.parse(result.output, decode.list(issue_comment_decoder())) - |> result.map_error(fn(_) { "Unable to decode issue comments." }) + True -> { + use comments <- result.try( + json.parse(result.output, decode.list(issue_comment_decoder())) + |> result.map_error(fn(_) { "Unable to decode issue comments." }), + ) + + let next_acc = list.append(acc, comments) + case list.length(comments) < 100 { + True -> Ok(next_acc) + False -> issue_comments_page(cwd, pr_number, page + 1, log_path, next_acc) + } + } False -> Error("Unable to inspect issue comments.") } } diff --git a/test/domain_pr_handoff_test.gleam b/test/domain_pr_handoff_test.gleam index 86f6a9e..cc13cd8 100644 --- a/test/domain_pr_handoff_test.gleam +++ b/test/domain_pr_handoff_test.gleam @@ -75,6 +75,21 @@ pub fn render_managed_comment_reports_delta_and_review_context_test() { assert string.contains(comment, pr_handoff.comment_marker("task-1")) } +pub fn render_body_region_omits_unknown_pr_number_from_scope_test() { + let body = + pr_handoff.render_body_region( + types.default_handoff_config(), + sample_run(), + types.Task(..sample_task(), pr_number: ""), + sample_execution_result(), + "$ gleam test", + pr_handoff.empty_snippets(), + ) + + assert !string.contains(does: body, contain: "PR: (none)") + assert !string.contains(does: body, contain: "\n- PR:") +} + fn sample_run() -> types.RunRecord { types.RunRecord( run_id: "run-123", diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 9c9aa5c..aae8dee 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -203,6 +203,63 @@ pub fn github_upsert_handoff_comment_updates_existing_comment_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn github_upsert_handoff_comment_finds_existing_marker_across_pages_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-gh-handoff-comment-pages-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let bin_dir = filepath.join(base_dir, "bin") + let fake_gh = filepath.join(bin_dir, "gh") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let comment_file = fake_gh <> ".comment.txt" + let pages_file = fake_gh <> ".comment-pages.json" + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = support.write_handoff_fake_gh(fake_gh) + let assert Ok(_) = + simplifile.write( + "{\"1\":[" + <> support.repeat_text("{\"id\":1,\"body\":\"noise\"},", 99) + <> "{\"id\":100,\"body\":\"noise\"}]," + <> "\"2\":[{\"id\":101,\"body\":\"Old body\\n\\n" + <> pr_handoff.comment_marker("task-1") + <> "\"}]}", + to: pages_file, + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) + + let assert Ok(github.CommentUpdated) = + github.upsert_handoff_comment( + repo_root, + 1, + "task-1", + "Updated body\n\n" <> pr_handoff.comment_marker("task-1"), + filepath.join(base_dir, "gh-update.log"), + ) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + + let assert Ok(comment_body) = simplifile.read(comment_file) + assert string.contains(comment_body, "Updated body") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn orchestrator_start_runs_fake_provider_test() { let unique = system.unique_id() let base_dir = diff --git a/test/night_shift_test_support.gleam b/test/night_shift_test_support.gleam index 2fe5fbf..af70cdb 100644 --- a/test/night_shift_test_support.gleam +++ b/test/night_shift_test_support.gleam @@ -1125,6 +1125,7 @@ pub fn write_handoff_fake_gh(path: String) -> Result(Nil, simplifile.FileError) "#!/bin/sh\n" <> "BODY_FILE=\"$0.body.txt\"\n" <> "COMMENT_FILE=\"$0.comment.txt\"\n" + <> "COMMENT_PAGES_FILE=\"$0.comment-pages.json\"\n" <> "if [ ! -f \"$BODY_FILE\" ]; then\n" <> " printf 'Legacy body\\n' > \"$BODY_FILE\"\n" <> "fi\n" @@ -1181,14 +1182,19 @@ pub fn write_handoff_fake_gh(path: String) -> Result(Nil, simplifile.FileError) <> " esac\n" <> " done\n" <> " case \"$METHOD:$PATH_ARG\" in\n" - <> " GET:repos/:owner/:repo/issues/1/comments)\n" - <> " python3 - <<'PY' \"$COMMENT_FILE\"\n" - <> "import json, os, sys\n" - <> "path = sys.argv[1]\n" - <> "if not os.path.exists(path):\n" + <> " GET:repos/:owner/:repo/issues/1/comments*)\n" + <> " python3 - <<'PY' \"$COMMENT_FILE\" \"$COMMENT_PAGES_FILE\" \"$PATH_ARG\"\n" + <> "import json, os, sys, urllib.parse\n" + <> "comment_path, pages_path, path_arg = sys.argv[1:4]\n" + <> "parsed = urllib.parse.urlparse('https://example.test/' + path_arg)\n" + <> "page = urllib.parse.parse_qs(parsed.query).get('page', ['1'])[0]\n" + <> "if os.path.exists(pages_path):\n" + <> " pages = json.load(open(pages_path))\n" + <> " print(json.dumps(pages.get(page, [])))\n" + <> "elif not os.path.exists(comment_path):\n" <> " print('[]')\n" <> "else:\n" - <> " body = open(path).read()\n" + <> " body = open(comment_path).read()\n" <> " print(json.dumps([{'id': 1, 'body': body}]))\n" <> "PY\n" <> " exit 0\n" @@ -1197,7 +1203,7 @@ pub fn write_handoff_fake_gh(path: String) -> Result(Nil, simplifile.FileError) <> " printf '%s' \"$BODY\" > \"$COMMENT_FILE\"\n" <> " exit 0\n" <> " ;;\n" - <> " PATCH:repos/:owner/:repo/issues/comments/1)\n" + <> " PATCH:repos/:owner/:repo/issues/comments/*)\n" <> " printf '%s' \"$BODY\" > \"$COMMENT_FILE\"\n" <> " exit 0\n" <> " ;;\n" From 4fa6de9e3ddc7555e0cb489d2fab05b1657080b5 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:43:37 -0700 Subject: [PATCH 07/13] Fix runtime identity port allocation and env quoting --- docs/worktree-environments.md | 14 +- .../orchestrator/execution_phase.gleam | 33 +++ src/night_shift/runtime_identity.gleam | 200 +++++++++++++++++- .../night_shift_execution_delivery_test.gleam | 6 +- test/runtime_identity_test.gleam | 81 ++++++- 5 files changed, 322 insertions(+), 12 deletions(-) diff --git a/docs/worktree-environments.md b/docs/worktree-environments.md index 79756a3..29d80fd 100644 --- a/docs/worktree-environments.md +++ b/docs/worktree-environments.md @@ -66,9 +66,10 @@ generated values. ## Runtime Identity Before Night Shift runs environment setup or provider execution for a task, it -derives a stable per-task runtime identity from the run ID and task ID. That -identity is persisted in the run journal and reused on resume, so an existing -task does not silently pick up new runtime values after a config edit. +derives a stable per-task runtime identity and assigns a deterministic port +block for that task. That identity is persisted in the run journal and reused +on resume, so an existing task does not silently pick up new runtime values +after a config edit. Night Shift always injects: @@ -88,7 +89,7 @@ normalized alias: - and so on The generated values are meant to be consumed by your existing setup scripts, -Compose files, and verification commands. Night Shift does not reserve ports, +shell wrappers, and verification commands. Night Shift does not reserve ports, start services, or manage secrets. ## Validation Rules @@ -111,8 +112,9 @@ the git worktree: - `./.night-shift/runs//runtime//night-shift.runtime.json` - `./.night-shift/runs//runtime//night-shift.handoff.md` -`night-shift.env` is plain `KEY=VALUE` output for shell scripts and Compose. -`night-shift.runtime.json` is the machine-readable source of truth. +`night-shift.env` is a shell-safe assignment file with quoted values. +`night-shift.runtime.json` is the machine-readable source of truth when another +tool needs exact values without shell parsing. `night-shift.handoff.md` is the short human-and-agent summary. ## Selection Rules diff --git a/src/night_shift/orchestrator/execution_phase.gleam b/src/night_shift/orchestrator/execution_phase.gleam index 54820c1..6f0322d 100644 --- a/src/night_shift/orchestrator/execution_phase.gleam +++ b/src/night_shift/orchestrator/execution_phase.gleam @@ -590,11 +590,22 @@ fn ensure_runtime_context( run.repo_root, run.environment_name, )) + use reserved_port_bases <- result.try(runtime_reserved_port_bases( + run.repo_root, + run.run_id, + )) + use port_base <- result.try(runtime_identity.allocate_port_base( + run.run_id, + run.tasks, + reserved_port_bases, + task.id, + )) use context <- result.try(runtime_identity.build_context( run.run_path, run.run_id, task.id, task.title, + port_base, named_ports, )) Ok(types.Task(..task, runtime_context: Some(context))) @@ -620,6 +631,28 @@ fn runtime_named_ports( } } +fn runtime_reserved_port_bases( + repo_root: String, + current_run_id: String, +) -> Result(List(Int), String) { + use runs <- result.try(journal.list_runs(repo_root)) + Ok( + runs + |> list.filter(fn(run) { run.run_id != current_run_id }) + |> list.flat_map(fn(run) { runtime_task_port_bases(run.tasks) }), + ) +} + +fn runtime_task_port_bases(tasks: List(types.Task)) -> List(Int) { + tasks + |> list.flat_map(fn(task) { + case task.runtime_context { + Some(context) -> [context.port_base] + None -> [] + } + }) +} + fn require_runtime_context( task: types.Task, ) -> Result(types.RuntimeContext, String) { diff --git a/src/night_shift/runtime_identity.gleam b/src/night_shift/runtime_identity.gleam index 3c96e18..5ebb372 100644 --- a/src/night_shift/runtime_identity.gleam +++ b/src/night_shift/runtime_identity.gleam @@ -2,9 +2,11 @@ import filepath import gleam/int import gleam/json import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import night_shift/project +import night_shift/shell import night_shift/types import simplifile @@ -36,6 +38,7 @@ pub fn build_context( run_id: String, task_id: String, task_title: String, + port_base: Int, named_port_names: List(String), ) -> Result(types.RuntimeContext, String) { use named_ports <- result.try(build_named_ports( @@ -43,12 +46,12 @@ pub fn build_context( task_id, named_port_names, )) + use _ <- result.try(validate_port_base(port_base)) let runtime_dir = project.task_runtime_root(run_path, task_id) let digest = sha256_hex(run_id <> ":" <> task_id) let hash_prefix = string.drop_end(digest, string.length(digest) - 8) let worktree_id = sanitize_task_slug(task_title) <> "-" <> hash_prefix let compose_project = "ns-" <> worktree_id - let port_base = 40_000 + { sha256_mod(run_id <> ":" <> task_id, 1000) * 20 } Ok(types.RuntimeContext( worktree_id: worktree_id, @@ -141,6 +144,24 @@ fn build_named_ports( ) } +pub fn allocate_port_base( + run_id: String, + tasks: List(types.Task), + reserved_port_bases: List(Int), + task_id: String, +) -> Result(Int, String) { + use reserved_slots <- result.try( + normalize_reserved_slots(reserved_port_bases, []), + ) + use assignments <- result.try( + assign_port_bases(run_id, tasks, reserved_slots, []), + ) + case find_assigned_port_base(assignments, task_id) { + Some(port_base) -> Ok(port_base) + None -> Error("Unable to allocate a runtime port base for task " <> task_id) + } +} + fn assign_port_values( port_base: Int, named_ports: List(types.RuntimePort), @@ -159,7 +180,7 @@ fn assign_port_values( fn render_env_file(context: types.RuntimeContext) -> String { env_vars(context) - |> list.map(fn(entry) { entry.0 <> "=" <> entry.1 }) + |> list.map(fn(entry) { entry.0 <> "=" <> shell.quote(entry.1) }) |> string.join(with: "\n") |> append_newline } @@ -251,3 +272,178 @@ fn describe_write_error(action: String) -> fn(simplifile.FileError) -> String { "Unable to " <> action <> ": " <> simplifile.describe_error(error) } } + +fn assign_port_bases( + run_id: String, + tasks: List(types.Task), + occupied_slots: List(Int), + assignments: List(#(String, Int)), +) -> Result(List(#(String, Int)), String) { + case tasks { + [] -> Ok(assignments) + [task, ..rest] -> + case task.runtime_context { + Some(context) -> { + use slot <- result.try(port_slot_from_base(context.port_base)) + case list.contains(occupied_slots, slot) { + True -> + case assignment_matches(assignments, task.id, context.port_base) { + True -> + assign_port_bases(run_id, rest, occupied_slots, assignments) + False -> + Error( + "Runtime port base collision detected for persisted task " + <> task.id + <> ".", + ) + } + False -> + assign_port_bases(run_id, rest, [slot, ..occupied_slots], [ + #(task.id, context.port_base), + ..assignments + ]) + } + } + None -> { + let preferred_slot = + sha256_mod(run_id <> ":" <> task.id, port_slot_count()) + use slot <- result.try(next_free_slot( + preferred_slot, + occupied_slots, + 0, + )) + let port_base = port_base_for_slot(slot) + assign_port_bases(run_id, rest, [slot, ..occupied_slots], [ + #(task.id, port_base), + ..assignments + ]) + } + } + } +} + +fn assignment_matches( + assignments: List(#(String, Int)), + task_id: String, + port_base: Int, +) -> Bool { + case find_assigned_port_base(assignments, task_id) { + Some(existing) -> existing == port_base + None -> False + } +} + +fn find_assigned_port_base( + assignments: List(#(String, Int)), + task_id: String, +) -> Option(Int) { + case assignments { + [] -> None + [assignment, ..rest] -> + case assignment.0 == task_id { + True -> Some(assignment.1) + False -> find_assigned_port_base(rest, task_id) + } + } +} + +fn normalize_reserved_slots( + reserved_port_bases: List(Int), + occupied_slots: List(Int), +) -> Result(List(Int), String) { + case reserved_port_bases { + [] -> Ok(occupied_slots) + [port_base, ..rest] -> { + use slot <- result.try(port_slot_from_base(port_base)) + case list.contains(occupied_slots, slot) { + True -> normalize_reserved_slots(rest, occupied_slots) + False -> normalize_reserved_slots(rest, [slot, ..occupied_slots]) + } + } + } +} + +fn next_free_slot( + slot: Int, + occupied_slots: List(Int), + attempts: Int, +) -> Result(Int, String) { + case attempts >= port_slot_count() { + True -> + Error( + "Night Shift ran out of runtime port blocks. Reduce retained worktrees or task count.", + ) + False -> + case list.contains(occupied_slots, slot) { + False -> Ok(slot) + True -> + next_free_slot(wrap_slot(slot + 1), occupied_slots, attempts + 1) + } + } +} + +fn validate_port_base(port_base: Int) -> Result(Nil, String) { + use _ <- result.try(port_slot_from_base(port_base)) + Ok(Nil) +} + +fn port_slot_from_base(port_base: Int) -> Result(Int, String) { + let first = first_port_base() + case port_base < first { + True -> + Error( + "Runtime port base " + <> int.to_string(port_base) + <> " is below the supported range.", + ) + False -> locate_port_slot(port_base, first, 0) + } +} + +fn locate_port_slot( + port_base: Int, + current_base: Int, + slot: Int, +) -> Result(Int, String) { + case current_base == port_base { + True -> Ok(slot) + False -> + case slot + 1 >= port_slot_count() { + True -> + Error( + "Runtime port base " + <> int.to_string(port_base) + <> " is outside Night Shift's supported port blocks.", + ) + False -> + locate_port_slot( + port_base, + current_base + port_block_size(), + slot + 1, + ) + } + } +} + +fn port_base_for_slot(slot: Int) -> Int { + first_port_base() + slot * port_block_size() +} + +fn first_port_base() -> Int { + 40_000 +} + +fn port_block_size() -> Int { + 20 +} + +fn port_slot_count() -> Int { + 1277 +} + +fn wrap_slot(slot: Int) -> Int { + case slot >= port_slot_count() { + True -> 0 + False -> slot + } +} diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 2c23a3b..eb38c73 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -687,9 +687,11 @@ pub fn orchestrator_start_generates_runtime_identity_artifacts_test() { assert string.contains( does: env_contents, - contain: "NIGHT_SHIFT_COMPOSE_PROJECT=" <> runtime_context.compose_project, + contain: "NIGHT_SHIFT_COMPOSE_PROJECT='" + <> runtime_context.compose_project + <> "'", ) - assert string.contains(does: env_contents, contain: "NIGHT_SHIFT_PORT_WEB=") + assert string.contains(does: env_contents, contain: "NIGHT_SHIFT_PORT_WEB='") assert string.contains( does: compose_project_contents, contain: runtime_context.compose_project, diff --git a/test/runtime_identity_test.gleam b/test/runtime_identity_test.gleam index bd4678c..3c30fcd 100644 --- a/test/runtime_identity_test.gleam +++ b/test/runtime_identity_test.gleam @@ -159,6 +159,7 @@ pub fn runtime_identity_build_context_is_deterministic_test() { "run-123", "demo-task", "Demo Task", + 41_000, ["web", "api"], ) let assert Ok(second) = @@ -167,6 +168,7 @@ pub fn runtime_identity_build_context_is_deterministic_test() { "run-123", "demo-task", "Demo Task", + 41_000, ["web", "api"], ) @@ -182,7 +184,7 @@ pub fn runtime_identity_ensure_artifacts_writes_env_manifest_and_handoff_test() let base_dir = filepath.join( system.state_directory(), - "night-shift-runtime-identity-" <> unique, + "night shift runtime identity " <> unique, ) let _ = simplifile.delete(file_or_dir_at: base_dir) let assert Ok(context) = @@ -191,6 +193,7 @@ pub fn runtime_identity_ensure_artifacts_writes_env_manifest_and_handoff_test() "run-456", "demo-task", "Demo Task", + 41_020, ["web"], ) let task = @@ -226,7 +229,11 @@ pub fn runtime_identity_ensure_artifacts_writes_env_manifest_and_handoff_test() assert string.contains( does: env_contents, - contain: "NIGHT_SHIFT_COMPOSE_PROJECT=", + contain: "NIGHT_SHIFT_COMPOSE_PROJECT='", + ) + assert string.contains( + does: env_contents, + contain: "NIGHT_SHIFT_RUNTIME_DIR='" <> context.runtime_dir <> "'", ) assert string.contains( does: manifest_contents, @@ -237,6 +244,44 @@ pub fn runtime_identity_ensure_artifacts_writes_env_manifest_and_handoff_test() let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn runtime_identity_allocate_port_base_is_unique_within_run_test() { + let tasks = [ + task_fixture("task-a"), + task_fixture("task-b"), + task_fixture("task-c"), + task_fixture("task-d"), + ] + + let assert Ok(port_base_a) = + runtime_identity.allocate_port_base("run-ports", tasks, [], "task-a") + let assert Ok(port_base_b) = + runtime_identity.allocate_port_base("run-ports", tasks, [], "task-b") + let assert Ok(port_base_c) = + runtime_identity.allocate_port_base("run-ports", tasks, [], "task-c") + let assert Ok(port_base_d) = + runtime_identity.allocate_port_base("run-ports", tasks, [], "task-d") + + assert unique_int_count( + [port_base_a, port_base_b, port_base_c, port_base_d], + [], + ) + == 4 +} + +pub fn runtime_identity_allocate_port_base_skips_reserved_blocks_test() { + let tasks = [task_fixture("task-a"), task_fixture("task-b")] + + let assert Ok(port_base_a) = + runtime_identity.allocate_port_base( + "run-reserved", + tasks, + [40_000], + "task-a", + ) + + assert port_base_a != 40_000 +} + fn build_port_names(count: Int, acc: List(String)) -> List(String) { case count <= 0 { True -> list.reverse(acc) @@ -254,3 +299,35 @@ fn render_port_list(values: List(String)) -> String { } <> "]" } + +fn task_fixture(task_id: String) -> types.Task { + types.Task( + id: task_id, + title: task_id, + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Ready, + worktree_path: "", + branch_name: "", + pr_number: "", + summary: "", + runtime_context: None, + ) +} + +fn unique_int_count(values: List(Int), seen: List(Int)) -> Int { + case values { + [] -> list.length(seen) + [value, ..rest] -> + case list.contains(seen, value) { + True -> unique_int_count(rest, seen) + False -> unique_int_count(rest, [value, ..seen]) + } + } +} From 0edf57368d92f950bf58281fe4427f669a5df78a Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:57:51 -0700 Subject: [PATCH 08/13] Format handoff delivery changes --- src/night_shift/codec/journal.gleam | 148 ++++++++-------- src/night_shift/config.gleam | 163 ++++++++---------- src/night_shift/domain/pr_handoff.gleam | 65 +++++-- src/night_shift/github.gleam | 31 ++-- src/night_shift/infra/task_delivery.gleam | 99 ++++++----- src/night_shift/types.gleam | 5 +- test/domain_pr_handoff_test.gleam | 24 ++- .../night_shift_execution_delivery_test.gleam | 6 +- 8 files changed, 284 insertions(+), 257 deletions(-) diff --git a/src/night_shift/codec/journal.gleam b/src/night_shift/codec/journal.gleam index 109baa8..8005ec1 100644 --- a/src/night_shift/codec/journal.gleam +++ b/src/night_shift/codec/journal.gleam @@ -44,7 +44,10 @@ pub fn encode_run(run: types.RunRecord) -> String { #("created_at", json.string(run.created_at)), #("updated_at", json.string(run.updated_at)), #("tasks", json.array(run.tasks, encode_task)), - #("handoff_states", json.array(run.handoff_states, encode_task_handoff_state)), + #( + "handoff_states", + json.array(run.handoff_states, encode_task_handoff_state), + ), ]) |> json.to_string } @@ -198,10 +201,7 @@ fn encode_task_handoff_state(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_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)), @@ -285,49 +285,51 @@ fn run_decoder() -> decode.Decoder(types.RunRecord) { None, decode.optional(decode.list(task_handoff_state_decoder())), ) - decode.success(types.RunRecord( - run_id: run_id, - repo_root: repo_root, - run_path: run_path, - brief_path: brief_path, - state_path: state_path, - events_path: events_path, - report_path: report_path, - lock_path: lock_path, - planning_agent: planning_agent, - execution_agent: execution_agent, - environment_name: case maybe_environment_name { - Some(name) -> name - None -> "" - }, - max_workers: max_workers, - notes_source: notes_source, - planning_provenance: case planning_provenance { - Some(provenance) -> Some(provenance) - None -> - case notes_source { - Some(source) -> Some(types.NotesOnly(source)) - None -> None - } - }, - repo_state_snapshot: repo_state_snapshot, - decisions: case decisions { - Some(entries) -> entries - None -> [] - }, - planning_dirty: case planning_dirty { - Some(value) -> value - None -> False - }, - status: status, - created_at: created_at, - updated_at: updated_at, - tasks: tasks, - handoff_states: case handoff_states { - Some(entries) -> entries - None -> [] - }, - )) + decode.success( + types.RunRecord( + run_id: run_id, + repo_root: repo_root, + run_path: run_path, + brief_path: brief_path, + state_path: state_path, + events_path: events_path, + report_path: report_path, + lock_path: lock_path, + planning_agent: planning_agent, + execution_agent: execution_agent, + environment_name: case maybe_environment_name { + Some(name) -> name + None -> "" + }, + max_workers: max_workers, + notes_source: notes_source, + planning_provenance: case planning_provenance { + Some(provenance) -> Some(provenance) + None -> + case notes_source { + Some(source) -> Some(types.NotesOnly(source)) + None -> None + } + }, + repo_state_snapshot: repo_state_snapshot, + decisions: case decisions { + Some(entries) -> entries + None -> [] + }, + planning_dirty: case planning_dirty { + Some(value) -> value + None -> False + }, + status: status, + created_at: created_at, + updated_at: updated_at, + tasks: tasks, + handoff_states: case handoff_states { + Some(entries) -> entries + None -> [] + }, + ), + ) } fn legacy_run_decoder() -> decode.Decoder(types.RunRecord) { @@ -346,30 +348,32 @@ fn legacy_run_decoder() -> decode.Decoder(types.RunRecord) { use updated_at <- decode.field("updated_at", decode.string) use tasks <- decode.field("tasks", decode.list(task_decoder())) let resolved_agent = types.resolved_agent_from_provider(provider) - decode.success(types.RunRecord( - run_id: run_id, - repo_root: repo_root, - run_path: run_path, - brief_path: brief_path, - state_path: state_path, - events_path: events_path, - report_path: report_path, - lock_path: lock_path, - planning_agent: resolved_agent, - execution_agent: resolved_agent, - environment_name: "", - max_workers: max_workers, - notes_source: None, - planning_provenance: None, - repo_state_snapshot: None, - decisions: [], - planning_dirty: False, - status: status, - created_at: created_at, - updated_at: updated_at, - tasks: tasks, - handoff_states: [], - )) + decode.success( + types.RunRecord( + run_id: run_id, + repo_root: repo_root, + run_path: run_path, + brief_path: brief_path, + state_path: state_path, + events_path: events_path, + report_path: report_path, + lock_path: lock_path, + planning_agent: resolved_agent, + execution_agent: resolved_agent, + environment_name: "", + max_workers: max_workers, + notes_source: None, + planning_provenance: None, + repo_state_snapshot: None, + decisions: [], + planning_dirty: False, + status: status, + created_at: created_at, + updated_at: updated_at, + tasks: tasks, + handoff_states: [], + ), + ) } fn resolved_agent_decoder() -> decode.Decoder(types.ResolvedAgentConfig) { diff --git a/src/night_shift/config.gleam b/src/night_shift/config.gleam index 6a45761..e4670e6 100644 --- a/src/night_shift/config.gleam +++ b/src/night_shift/config.gleam @@ -62,11 +62,10 @@ pub fn render(config: types.Config) -> String { <> shared.render_string_list(config.verification_commands) } - let handoff_lines = - case config.handoff == types.default_handoff_config() { - True -> "" - False -> render_handoff(config.handoff) - } + let handoff_lines = case config.handoff == types.default_handoff_config() { + True -> "" + False -> render_handoff(config.handoff) + } string.join(root_lines, with: "\n") <> "\n\n" @@ -259,10 +258,7 @@ fn apply_value( Ok(ParseState( types.Config( ..config, - handoff: types.HandoffConfig( - ..config.handoff, - managed_comment: value, - ), + handoff: types.HandoffConfig(..config.handoff, managed_comment: value), ), state.section, )) @@ -283,76 +279,44 @@ fn apply_value( } HandoffSection, "include_files_touched" -> - parse_handoff_bool_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, include_files_touched: value) - }, - ) + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_files_touched: value) + }) HandoffSection, "include_acceptance" -> - parse_handoff_bool_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, include_acceptance: value) - }, - ) + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_acceptance: value) + }) HandoffSection, "include_stack_context" -> - parse_handoff_bool_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, include_stack_context: value) - }, - ) + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_stack_context: value) + }) HandoffSection, "include_verification_summary" -> - parse_handoff_bool_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, include_verification_summary: value) - }, - ) + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_verification_summary: value) + }) HandoffSection, "pr_body_prefix_path" -> - parse_handoff_path_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, pr_body_prefix_path: value) - }, - ) + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, pr_body_prefix_path: value) + }) HandoffSection, "pr_body_suffix_path" -> - parse_handoff_path_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, pr_body_suffix_path: value) - }, - ) + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, pr_body_suffix_path: value) + }) HandoffSection, "comment_prefix_path" -> - parse_handoff_path_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, comment_prefix_path: value) - }, - ) + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, comment_prefix_path: value) + }) HandoffSection, "comment_suffix_path" -> - parse_handoff_path_field( - state, - raw_value, - fn(handoff, value) { - types.HandoffConfig(..handoff, comment_suffix_path: value) - }, - ) + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, comment_suffix_path: value) + }) ProfileSection(profile_name), "provider" -> { use provider <- result.try( @@ -462,15 +426,15 @@ fn parse_handoff_bool_field( fn parse_handoff_path_field( state: ParseState, raw_value: String, - update: fn( - types.HandoffConfig, - Option(String), - ) -> types.HandoffConfig, + update: fn(types.HandoffConfig, Option(String)) -> types.HandoffConfig, ) -> Result(ParseState, String) { Ok(ParseState( types.Config( ..state.config, - handoff: update(state.config.handoff, shared.parse_optional_string(raw_value)), + handoff: update( + state.config.handoff, + shared.parse_optional_string(raw_value), + ), ), state.section, )) @@ -565,29 +529,42 @@ fn render_profile(profile: types.AgentProfile) -> String { } fn render_handoff(handoff: types.HandoffConfig) -> String { - let lines = [ - "", - "[handoff]", - "enabled = " <> render_bool(handoff.enabled), - "pr_body_mode = " - <> shared.render_string( - types.handoff_body_mode_to_string(handoff.pr_body_mode), - ), - "managed_comment = " <> render_bool(handoff.managed_comment), - "provenance = " - <> shared.render_string( - types.handoff_provenance_to_string(handoff.provenance), - ), - "include_files_touched = " <> render_bool(handoff.include_files_touched), - "include_acceptance = " <> render_bool(handoff.include_acceptance), - "include_stack_context = " <> render_bool(handoff.include_stack_context), - "include_verification_summary = " - <> render_bool(handoff.include_verification_summary), - ] - |> list.append(optional_handoff_path("pr_body_prefix_path", handoff.pr_body_prefix_path)) - |> list.append(optional_handoff_path("pr_body_suffix_path", handoff.pr_body_suffix_path)) - |> list.append(optional_handoff_path("comment_prefix_path", handoff.comment_prefix_path)) - |> list.append(optional_handoff_path("comment_suffix_path", handoff.comment_suffix_path)) + let lines = + [ + "", + "[handoff]", + "enabled = " <> render_bool(handoff.enabled), + "pr_body_mode = " + <> shared.render_string(types.handoff_body_mode_to_string( + handoff.pr_body_mode, + )), + "managed_comment = " <> render_bool(handoff.managed_comment), + "provenance = " + <> shared.render_string(types.handoff_provenance_to_string( + handoff.provenance, + )), + "include_files_touched = " <> render_bool(handoff.include_files_touched), + "include_acceptance = " <> render_bool(handoff.include_acceptance), + "include_stack_context = " <> render_bool(handoff.include_stack_context), + "include_verification_summary = " + <> render_bool(handoff.include_verification_summary), + ] + |> list.append(optional_handoff_path( + "pr_body_prefix_path", + handoff.pr_body_prefix_path, + )) + |> list.append(optional_handoff_path( + "pr_body_suffix_path", + handoff.pr_body_suffix_path, + )) + |> list.append(optional_handoff_path( + "comment_prefix_path", + handoff.comment_prefix_path, + )) + |> list.append(optional_handoff_path( + "comment_suffix_path", + handoff.comment_suffix_path, + )) "\n" <> string.join(lines, with: "\n") } diff --git a/src/night_shift/domain/pr_handoff.gleam b/src/night_shift/domain/pr_handoff.gleam index dd6c171..fa0bee3 100644 --- a/src/night_shift/domain/pr_handoff.gleam +++ b/src/night_shift/domain/pr_handoff.gleam @@ -6,6 +6,7 @@ import night_shift/domain/repo_state import night_shift/types pub const body_start_marker = "" + pub const body_end_marker = "" pub type Snippets { @@ -62,7 +63,13 @@ pub fn render_body_region( "## Summary\n" <> fallback_text(execution_result.pr.summary), render_evidence(handoff, execution_result, verification_output), "## Known Risks\n" <> bullet_list(execution_result.pr.risks), - render_provenance(handoff.provenance, run, task, execution_result, verification_output), + render_provenance( + handoff.provenance, + run, + task, + execution_result, + verification_output, + ), render_optional(snippets.body_suffix), ] |> list.filter(fn(section) { string.trim(section) != "" }) @@ -118,11 +125,15 @@ fn render_scope( ) -> String { let scope_lines = [] let scope_lines = case handoff.include_files_touched { - True -> list.append(scope_lines, ["Files touched: " <> inline_list(execution_result.files_touched)]) + True -> + list.append(scope_lines, [ + "Files touched: " <> inline_list(execution_result.files_touched), + ]) False -> scope_lines } let scope_lines = case handoff.include_acceptance { - True -> list.append(scope_lines, ["Acceptance: " <> inline_list(task.acceptance)]) + True -> + list.append(scope_lines, ["Acceptance: " <> inline_list(task.acceptance)]) False -> scope_lines } let scope_lines = case handoff.include_stack_context { @@ -145,15 +156,18 @@ fn render_evidence( execution_result: types.ExecutionResult, verification_output: String, ) -> String { - let sections = ["Demo evidence:\n" <> bullet_list(execution_result.demo_evidence)] + let sections = [ + "Demo evidence:\n" <> bullet_list(execution_result.demo_evidence), + ] let sections = case handoff.include_verification_summary { - True -> list.append(sections, [ - "Verification digest: " + True -> + list.append(sections, [ + "Verification digest: " <> verification_digest(verification_output) <> "\n\n```text\n" <> verification_output <> "\n```", - ]) + ]) False -> sections } @@ -213,11 +227,22 @@ fn render_delta( Some(state) -> bullet_list([ "Added files: " - <> inline_list(list_difference(current_files, state.last_handoff_files)), + <> inline_list(list_difference( + current_files, + state.last_handoff_files, + )), "Removed files: " - <> inline_list(list_difference(state.last_handoff_files, current_files)), - "Verification changed: " <> bool_label(state.last_verification_digest != current_digest), - "Risks changed: " <> bool_label(string.join(state.last_risks, with: "\n") != string.join(current_risks, with: "\n")), + <> inline_list(list_difference( + state.last_handoff_files, + current_files, + )), + "Verification changed: " + <> bool_label(state.last_verification_digest != current_digest), + "Risks changed: " + <> bool_label( + string.join(state.last_risks, with: "\n") + != string.join(current_risks, with: "\n"), + ), ]) } } @@ -225,7 +250,10 @@ fn render_delta( fn render_review_feedback_status(run: types.RunRecord) -> String { case run.planning_provenance { Some(provenance) -> - case types.planning_provenance_uses_reviews(provenance), run.repo_state_snapshot { + case + types.planning_provenance_uses_reviews(provenance), + run.repo_state_snapshot + { True, Some(snapshot) -> { let actionable_lines = snapshot.open_pull_requests @@ -245,7 +273,8 @@ fn render_review_feedback_status(run: types.RunRecord) -> String { }) case actionable_lines { - [] -> "- Review-driven run, but no actionable comments or failing checks were captured." + [] -> + "- Review-driven run, but no actionable comments or failing checks were captured." _ -> bullet_list(actionable_lines) } } @@ -292,11 +321,14 @@ fn agent_summary(agent: types.ResolvedAgentConfig) -> String { Some(reasoning) -> " reasoning=" <> types.reasoning_to_string(reasoning) None -> "" } - types.provider_to_string(agent.provider) <> model_fragment <> reasoning_fragment + types.provider_to_string(agent.provider) + <> model_fragment + <> reasoning_fragment } fn bullet_list(items: List(String)) -> String { - case items + case + items |> list.filter(fn(item) { string.trim(item) != "" }) { [] -> "- None" @@ -325,7 +357,8 @@ fn render_pr_numbers(pr_numbers: List(Int)) -> String { } fn inline_list(items: List(String)) -> String { - case items + case + items |> list.filter(fn(item) { string.trim(item) != "" }) { [] -> "(none)" diff --git a/src/night_shift/github.gleam b/src/night_shift/github.gleam index 08d711a..9c297cb 100644 --- a/src/night_shift/github.gleam +++ b/src/night_shift/github.gleam @@ -114,9 +114,11 @@ pub fn upsert_handoff_comment( use comments <- result.try(issue_comments(cwd, pr_number, log_path)) let marker = pr_handoff.comment_marker(task_id) - case list.find(comments, fn(comment) { - string.contains(does: comment.body, contain: marker) - }) { + case + list.find(comments, fn(comment) { + string.contains(does: comment.body, contain: marker) + }) + { Ok(comment) -> { use _ <- result.try(update_issue_comment(cwd, comment.id, body, log_path)) Ok(CommentUpdated) @@ -243,9 +245,7 @@ fn existing_pr_body( log_path: String, ) -> Result(String, String) { let command = - gh_pr_command("view ") - <> int.to_string(pr_number) - <> " --json body" + gh_pr_command("view ") <> int.to_string(pr_number) <> " --json body" let result = shell.run(command, cwd, log_path) case shell.succeeded(result) { @@ -284,18 +284,14 @@ fn compose_pr_body( } } -fn replace_handoff_region(existing_body: String, next_region: String) -> Option(String) { - case - string.split_once(existing_body, pr_handoff.body_start_marker) - { +fn replace_handoff_region( + existing_body: String, + next_region: String, +) -> Option(String) { + case string.split_once(existing_body, pr_handoff.body_start_marker) { Ok(#(before, remainder)) -> case string.split_once(remainder, pr_handoff.body_end_marker) { - Ok(#(_, after)) -> - Some( - before - <> next_region - <> after, - ) + Ok(#(_, after)) -> Some(before <> next_region <> after) Error(_) -> None } Error(_) -> None @@ -416,7 +412,8 @@ fn issue_comments_page( let next_acc = list.append(acc, comments) case list.length(comments) < 100 { True -> Ok(next_acc) - False -> issue_comments_page(cwd, pr_number, page + 1, log_path, next_acc) + False -> + issue_comments_page(cwd, pr_number, page + 1, log_path, next_acc) } } False -> Error("Unable to inspect issue comments.") diff --git a/src/night_shift/infra/task_delivery.gleam b/src/night_shift/infra/task_delivery.gleam index bf81a51..dac69c9 100644 --- a/src/night_shift/infra/task_delivery.gleam +++ b/src/night_shift/infra/task_delivery.gleam @@ -55,7 +55,8 @@ pub fn deliver_completed_task( git.push_branch(task_run.worktree_path, task_run.branch_name, git_log) |> result.map_error(git_delivery_error), ) - let snippets_and_events = load_snippets(run.repo_root, config.handoff, task_run.task.id) + let snippets_and_events = + load_snippets(run.repo_root, config.handoff, task_run.task.id) let #(snippets, snippet_events) = snippets_and_events let legacy_body = pull_request_domain.render_legacy_body( @@ -64,9 +65,7 @@ pub fn deliver_completed_task( execution_result, verification_output, ) - let handoff_region = case - pr_handoff.body_region_enabled(config.handoff) - { + let handoff_region = case pr_handoff.body_region_enabled(config.handoff) { True -> Some(pr_handoff.render_body_region( config.handoff, @@ -135,37 +134,31 @@ pub fn deliver_completed_task( git_log, ) { - Ok(github.CommentCreated) -> - #( - True, - [handoff_event( - "pr_handoff_created", - task_run.task.id, - "Created managed PR handoff comment for PR #" - <> int.to_string(pull_request.number) - <> ".", - )], - ) - Ok(github.CommentUpdated) -> - #( - True, - [handoff_event( - "pr_handoff_updated", - task_run.task.id, - "Updated managed PR handoff comment for PR #" - <> int.to_string(pull_request.number) - <> ".", - )], - ) - Error(message) -> - #( - False, - [handoff_event( - "pr_handoff_warning", - task_run.task.id, - "Unable to update the managed PR handoff comment: " <> message, - )], - ) + Ok(github.CommentCreated) -> #(True, [ + handoff_event( + "pr_handoff_created", + task_run.task.id, + "Created managed PR handoff comment for PR #" + <> int.to_string(pull_request.number) + <> ".", + ), + ]) + Ok(github.CommentUpdated) -> #(True, [ + handoff_event( + "pr_handoff_updated", + task_run.task.id, + "Updated managed PR handoff comment for PR #" + <> int.to_string(pull_request.number) + <> ".", + ), + ]) + Error(message) -> #(False, [ + handoff_event( + "pr_handoff_warning", + task_run.task.id, + "Unable to update the managed PR handoff comment: " <> message, + ), + ]) } } False -> #(False, []) @@ -189,24 +182,26 @@ pub fn deliver_completed_task( managed_comment_present: managed_comment_present, ) let base_handoff_event = case previous_state { - Some(_) -> - [handoff_event( + Some(_) -> [ + handoff_event( "pr_handoff_updated", task_run.task.id, case body_region_present { True -> "Updated Night Shift PR handoff metadata." False -> "Persisted Night Shift PR handoff state." }, - )] - None -> - [handoff_event( + ), + ] + None -> [ + handoff_event( "pr_handoff_created", task_run.task.id, case body_region_present { True -> "Created Night Shift PR handoff metadata." False -> "Created Night Shift PR handoff state." }, - )] + ), + ] } Ok(Delivered( pr_number: int.to_string(pull_request.number), @@ -285,21 +280,23 @@ fn load_snippet( case simplifile.read(absolute_path) { Ok(contents) -> #(Some(contents), []) - Error(_) -> - #( - None, - [handoff_event( - "pr_handoff_warning", - task_id, - "Unable to read handoff snippet at " <> path <> ".", - )], - ) + Error(_) -> #(None, [ + handoff_event( + "pr_handoff_warning", + task_id, + "Unable to read handoff snippet at " <> path <> ".", + ), + ]) } } } } -fn handoff_event(kind: String, task_id: String, message: String) -> types.RunEvent { +fn handoff_event( + kind: String, + task_id: String, + message: String, +) -> types.RunEvent { types.RunEvent( kind: kind, at: system.timestamp(), diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index 1229cd9..da2d454 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -580,7 +580,10 @@ pub fn replace_task_handoff_state( ) -> List(TaskHandoffState) { case handoff_states { [] -> [next_state] - [state, ..rest] if state.task_id == next_state.task_id -> [next_state, ..rest] + [state, ..rest] if state.task_id == next_state.task_id -> [ + next_state, + ..rest + ] [state, ..rest] -> [state, ..replace_task_handoff_state(rest, next_state)] } } diff --git a/test/domain_pr_handoff_test.gleam b/test/domain_pr_handoff_test.gleam index cc13cd8..0d71098 100644 --- a/test/domain_pr_handoff_test.gleam +++ b/test/domain_pr_handoff_test.gleam @@ -29,8 +29,14 @@ pub fn render_body_region_includes_sections_and_snippets_test() { assert string.contains(body, "Team prefix") assert string.contains(body, "## Context") assert string.contains(body, "## Scope") - assert string.contains(body, "Files touched: src/app.gleam, test/app_test.gleam") - assert string.contains(body, "Acceptance: Add the app entrypoint, Cover the happy path") + assert string.contains( + body, + "Files touched: src/app.gleam, test/app_test.gleam", + ) + assert string.contains( + body, + "Acceptance: Add the app entrypoint, Cover the happy path", + ) assert string.contains(body, "## Evidence") assert string.contains(body, "Verification digest:") assert string.contains(body, "## Provenance") @@ -65,11 +71,17 @@ pub fn render_managed_comment_reports_delta_and_review_context_test() { ) assert string.contains(comment, "## Since Last Review") - assert string.contains(comment, "Added files: src/app.gleam, test/app_test.gleam") + assert string.contains( + comment, + "Added files: src/app.gleam, test/app_test.gleam", + ) assert string.contains(comment, "Removed files: src/old.gleam") assert string.contains(comment, "Verification changed: yes") assert string.contains(comment, "## Review Feedback Status") - assert string.contains(comment, "#11: Review COMMENTED: Please make QA_NOTES.md the canonical doc.") + assert string.contains( + comment, + "#11: Review COMMENTED: Please make QA_NOTES.md the canonical doc.", + ) assert string.contains(comment, "## Stack / Replacement Status") assert string.contains(comment, "Repo-state drift: yes") assert string.contains(comment, pr_handoff.comment_marker("task-1")) @@ -121,7 +133,9 @@ fn sample_review_run() -> types.RunRecord { types.RunRecord( ..sample_run(), planning_provenance: Some(types.ReviewsOnly), - repo_state_snapshot: Some(night_shift_test_support.sample_repo_state_snapshot()), + repo_state_snapshot: Some( + night_shift_test_support.sample_repo_state_snapshot(), + ), ) } diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index aae8dee..8eab36e 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -3,6 +3,7 @@ import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/string +import night_shift/domain/pr_handoff import night_shift/github import night_shift/journal import night_shift/orchestrator @@ -12,7 +13,6 @@ import night_shift/shell import night_shift/system import night_shift/types import night_shift/worktree_setup -import night_shift/domain/pr_handoff import night_shift_test_support as support import simplifile @@ -129,7 +129,9 @@ pub fn github_open_or_update_pr_preserves_manual_body_outside_handoff_region_tes "main", "Demo PR", "Legacy body", - Some("\nnew body\n"), + Some( + "\nnew body\n", + ), types.default_handoff_config(), run_path, filepath.join(run_path, "logs/gh.log"), From 78706191406fda53f9bfc03828d1f3dab2efc789 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 16:00:00 -0700 Subject: [PATCH 09/13] Use portable shell for local CLI test helper --- test/night_shift_test_support.gleam | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/night_shift_test_support.gleam b/test/night_shift_test_support.gleam index 51932b0..908ebd3 100644 --- a/test/night_shift_test_support.gleam +++ b/test/night_shift_test_support.gleam @@ -36,7 +36,7 @@ pub fn initialize_project_home( } pub fn local_demo_command() -> String { - "zsh -lc " + "sh -lc " <> shell.quote( "cd " <> shell.quote(system.cwd()) <> " && gleam run -- \"$@\"", ) From 4460d3ab4af329cecf5c97bc3cc3aa56530fcbab Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 16:09:21 -0700 Subject: [PATCH 10/13] Fix integrated test fixtures for runtime context --- test/domain_pr_handoff_test.gleam | 1 + test/trust_surface_test.gleam | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/test/domain_pr_handoff_test.gleam b/test/domain_pr_handoff_test.gleam index 0d71098..5507fc0 100644 --- a/test/domain_pr_handoff_test.gleam +++ b/test/domain_pr_handoff_test.gleam @@ -156,6 +156,7 @@ fn sample_task() -> types.Task { branch_name: "night-shift/task-1", pr_number: "15", summary: "", + runtime_context: None, ) } diff --git a/test/trust_surface_test.gleam b/test/trust_surface_test.gleam index 8b34568..a9059ad 100644 --- a/test/trust_surface_test.gleam +++ b/test/trust_surface_test.gleam @@ -140,6 +140,7 @@ pub fn doctor_flags_dirty_and_missing_worktrees_test() { branch_name: "night-shift/dirty-task", pr_number: "", summary: "", + runtime_context: None, ), types.Task( id: "missing-task", @@ -157,6 +158,7 @@ pub fn doctor_flags_dirty_and_missing_worktrees_test() { branch_name: "night-shift/missing-task", pr_number: "", summary: "", + runtime_context: None, ), ], ) @@ -216,6 +218,7 @@ pub fn doctor_does_not_write_probe_log_into_worktree_test() { branch_name: "night-shift/clean-task", pr_number: "", summary: "", + runtime_context: None, ), ], ) @@ -274,8 +277,10 @@ fn review_run() -> types.RunRecord { branch_name: "night-shift/rewrite-root", pr_number: "15", summary: "Updated rewrite-root", + runtime_context: None, ), ], + handoff_states: [], ) } From 45051aa2cf5714fb36cb36f25c7b9c62f629941e Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 08:25:57 -0700 Subject: [PATCH 11/13] Fix resume recovery log false positive --- src/night_shift/usecase/resume.gleam | 8 ++-- test/night_shift_resume_test.gleam | 70 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 test/night_shift_resume_test.gleam diff --git a/src/night_shift/usecase/resume.gleam b/src/night_shift/usecase/resume.gleam index 8b4c90e..e583cc6 100644 --- a/src/night_shift/usecase/resume.gleam +++ b/src/night_shift/usecase/resume.gleam @@ -39,10 +39,10 @@ pub fn execute( )) } -fn prepare_resumed_run(run: types.RunRecord) -> Result(types.RunRecord, String) { +pub fn prepare_resumed_run(run: types.RunRecord) -> Result(types.RunRecord, String) { let resumed_tasks = run.tasks - |> list.map(fn(task) { recover_task(task) }) + |> list.map(fn(task) { recover_task(run.run_path, task) }) |> task_graph.refresh_ready_states let resumed_run = types.RunRecord(..run, tasks: resumed_tasks) @@ -57,13 +57,13 @@ fn prepare_resumed_run(run: types.RunRecord) -> Result(types.RunRecord, String) journal.append_event(resumed_run, event) } -fn recover_task(task: types.Task) -> types.Task { +fn recover_task(run_path: String, task: types.Task) -> types.Task { let has_worktree_changes = case task.worktree_path { "" -> False worktree_path -> git.has_changes( worktree_path, - filepath.join(worktree_path, ".night-shift-recover.log"), + filepath.join(run_path, "logs/" <> task.id <> ".recover.has-changes.log"), ) } diff --git a/test/night_shift_resume_test.gleam b/test/night_shift_resume_test.gleam new file mode 100644 index 0000000..b2701dd --- /dev/null +++ b/test/night_shift_resume_test.gleam @@ -0,0 +1,70 @@ +import filepath +import gleam/option.{None} +import night_shift/git +import night_shift/journal +import night_shift/system +import night_shift/types +import night_shift/usecase/resume +import night_shift_test_support as support +import simplifile + +pub fn resume_keeps_clean_interrupted_worktree_requeueable_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "resume-clean-worktree-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let brief_path = filepath.join(base_dir, "brief.md") + let worktree_path = filepath.join(base_dir, "task-worktree") + let git_log = filepath.join(base_dir, "worktree-add.log") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = support.initialize_project_home(repo_root) + support.seed_git_repo(repo_root, base_dir) + let assert Ok(_) = simplifile.write("# Brief\n", to: brief_path) + let assert Ok(_) = + git.create_worktree( + repo_root, + worktree_path, + "night-shift/resume-clean-task", + "main", + git_log, + ) + let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let running_task = + types.Task( + id: "resume-clean-task", + title: "Resume clean task", + description: "Verify resume leaves a clean worktree resumable.", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: worktree_path, + branch_name: "night-shift/resume-clean-task", + pr_number: "", + summary: "", + runtime_context: None, + ) + let interrupted_run = types.RunRecord(..run, tasks: [running_task]) + let assert Ok(_) = journal.rewrite_run(interrupted_run) + + let assert Ok(resumed) = resume.prepare_resumed_run(interrupted_run) + let assert [task] = resumed.tasks + + assert task.state == types.Ready + assert task.summary == "" + assert + git.changed_files(worktree_path, filepath.join(base_dir, "status.log")) == [] + let assert Error(_) = + simplifile.read(filepath.join(worktree_path, ".night-shift-recover.log")) + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} From 72c8d7a286d00a496233de0bfdb6e249cf18fc2c Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 08:37:44 -0700 Subject: [PATCH 12/13] Preserve base branch on resumed delivery --- .../orchestrator/execution_phase.gleam | 6 +- .../night_shift_execution_delivery_test.gleam | 156 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/night_shift/orchestrator/execution_phase.gleam b/src/night_shift/orchestrator/execution_phase.gleam index 65bdf2b..8972b11 100644 --- a/src/night_shift/orchestrator/execution_phase.gleam +++ b/src/night_shift/orchestrator/execution_phase.gleam @@ -130,10 +130,8 @@ fn launch_batch_loop( let is_existing_worktree = task.branch_name != "" let default_worktree_path = filepath.join(run.run_path, "worktrees/" <> task.id) - let base_ref = case task.branch_name { - "" -> task_graph.task_base_ref(task, run.tasks, config.base_branch) - existing_branch -> existing_branch - } + let base_ref = + task_graph.task_base_ref(task, run.tasks, config.base_branch) let git_log = filepath.join(run.run_path, "logs/" <> task.id <> ".git.log") let env_log = diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 71ba9e2..a2a94f5 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -5,6 +5,7 @@ import gleam/option.{None, Some} import gleam/result import gleam/string import night_shift/domain/pr_handoff +import night_shift/git import night_shift/github import night_shift/journal import night_shift/orchestrator @@ -13,6 +14,7 @@ import night_shift/provider import night_shift/shell import night_shift/system import night_shift/types +import night_shift/usecase/resume import night_shift/worktree_setup import night_shift_test_support as support import simplifile @@ -531,6 +533,160 @@ pub fn orchestrator_start_preserves_partial_success_after_delivery_failure_test( let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn orchestrator_resume_preserves_original_base_ref_for_delivery_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-resume-base-ref-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let remote_root = filepath.join(base_dir, "remote.git") + let worktree_path = filepath.join(base_dir, "task-worktree") + let bin_dir = filepath.join(base_dir, "bin") + let brief_path = filepath.join(base_dir, "brief.md") + let fake_provider = filepath.join(bin_dir, "fake-provider") + let fake_gh = filepath.join(bin_dir, "gh") + let state_home = filepath.join(base_dir, "state") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") + let old_state_home = system.get_env("XDG_STATE_HOME") + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let _ = + simplifile.delete(file_or_dir_at: journal.repo_state_path_for(repo_root)) + let assert Ok(_) = simplifile.create_directory_all(base_dir) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = simplifile.write("# Brief", to: brief_path) + let assert Ok(_) = support.write_committing_fake_provider(fake_provider) + let assert Ok(_) = + simplifile.write( + "#!/bin/sh\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n" + <> " printf '[]\\n'\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n" + <> " BASE=''\n" + <> " HEAD=''\n" + <> " shift 2\n" + <> " while [ $# -gt 0 ]; do\n" + <> " case \"$1\" in\n" + <> " --base)\n" + <> " BASE=$2\n" + <> " shift 2\n" + <> " ;;\n" + <> " --head)\n" + <> " HEAD=$2\n" + <> " shift 2\n" + <> " ;;\n" + <> " *)\n" + <> " shift\n" + <> " ;;\n" + <> " esac\n" + <> " done\n" + <> " if [ \"$BASE\" = \"$HEAD\" ]; then\n" + <> " printf 'head branch \"%s\" is the same as base branch \"%s\", cannot create a pull request\\n' \"$HEAD\" \"$BASE\" >&2\n" + <> " exit 1\n" + <> " fi\n" + <> " if [ \"$BASE\" != \"main\" ]; then\n" + <> " printf 'expected base main, got %s\\n' \"$BASE\" >&2\n" + <> " exit 1\n" + <> " fi\n" + <> " printf 'https://example.test/pr/7\\n'\n" + <> " exit 0\n" + <> "fi\n" + <> "printf 'unsupported gh invocation: %s %s\\n' \"$1\" \"$2\" >&2\n" + <> "exit 1\n", + to: fake_gh, + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_provider) <> " " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + let _ = + shell.run( + "git init --bare " <> shell.quote(remote_root), + base_dir, + filepath.join(base_dir, "remote.log"), + ) + support.seed_git_repo(repo_root, base_dir) + let _ = + shell.run( + "git remote add origin " <> shell.quote(remote_root), + repo_root, + filepath.join(base_dir, "remote-add.log"), + ) + let _ = + shell.run( + "git push -u origin main", + repo_root, + filepath.join(base_dir, "push-main.log"), + ) + let assert Ok(_) = + git.create_worktree( + repo_root, + worktree_path, + "night-shift/resume-base-ref-task", + "main", + filepath.join(base_dir, "worktree.log"), + ) + + system.set_env("NIGHT_SHIFT_FAKE_PROVIDER", fake_provider) + 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(run) = support.start_run(repo_root, brief_path, types.Codex, 1) + let interrupted_task = + types.Task( + id: "demo-task", + title: "Implement demo task", + description: "Create a file to prove execution", + dependencies: [], + acceptance: ["Create IMPLEMENTED.md"], + demo_plan: ["Show the new file"], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: worktree_path, + branch_name: "night-shift/resume-base-ref-task", + pr_number: "", + summary: "", + runtime_context: None, + ) + let interrupted_run = types.RunRecord(..run, tasks: [interrupted_task]) + let assert Ok(_) = journal.rewrite_run(interrupted_run) + let assert Ok(resumed_run) = resume.prepare_resumed_run(interrupted_run) + let assert Ok(completed_run) = orchestrator.continue_run(resumed_run, config) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) + support.restore_env("XDG_STATE_HOME", old_state_home) + + let assert [completed_task] = completed_run.tasks + let assert Ok(report_contents) = simplifile.read(completed_run.report_path) + + assert completed_run.status == types.RunCompleted + assert completed_task.state == types.Completed + assert completed_task.pr_number == "7" + assert string.contains(does: report_contents, contain: "- Opened PRs: 1") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn orchestrator_start_delivers_provider_created_commit_test() { let unique = system.unique_id() let base_dir = From 0eea544c4fb6a8f6ca752386ef0eead752e4b423 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Tue, 14 Apr 2026 08:44:34 -0700 Subject: [PATCH 13/13] Format stacked QA branch --- src/night_shift/app.gleam | 3 +- src/night_shift/cli.gleam | 3 +- src/night_shift/codec/journal.gleam | 1 + src/night_shift/domain/confidence.gleam | 22 ++-- src/night_shift/domain/provenance.gleam | 153 +++++++++++++++-------- src/night_shift/infra/run_store.gleam | 5 +- src/night_shift/usecase/doctor.gleam | 66 +++++----- src/night_shift/usecase/provenance.gleam | 5 +- src/night_shift/usecase/resume.gleam | 9 +- src/night_shift/usecase/status.gleam | 3 +- test/night_shift_cli_config_test.gleam | 3 +- test/night_shift_resume_test.gleam | 4 +- test/trust_surface_test.gleam | 144 +++++++++++---------- 13 files changed, 241 insertions(+), 180 deletions(-) diff --git a/src/night_shift/app.gleam b/src/night_shift/app.gleam index cf60390..1a56d8e 100644 --- a/src/night_shift/app.gleam +++ b/src/night_shift/app.gleam @@ -137,7 +137,8 @@ fn run_initialized_command( 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, 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)) _ -> io.println("Unsupported command.") diff --git a/src/night_shift/cli.gleam b/src/night_shift/cli.gleam index 5694ba1..f9cc45b 100644 --- a/src/night_shift/cli.gleam +++ b/src/night_shift/cli.gleam @@ -280,8 +280,7 @@ fn parse_resume_flags( ["--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) + ["--explain", ..rest] -> parse_resume_flags(rest, run, ui_enabled, True) [flag, ..] -> Error("Unsupported flag: " <> flag) } } diff --git a/src/night_shift/codec/journal.gleam b/src/night_shift/codec/journal.gleam index 795489b..78e423d 100644 --- a/src/night_shift/codec/journal.gleam +++ b/src/night_shift/codec/journal.gleam @@ -561,6 +561,7 @@ fn runtime_port_decoder() -> decode.Decoder(types.RuntimePort) { use value <- decode.field("value", decode.int) decode.success(types.RuntimePort(name: name, value: value)) } + fn decision_request_decoder() -> decode.Decoder(types.DecisionRequest) { use key <- decode.field("key", decode.string) use question <- decode.field("question", decode.string) diff --git a/src/night_shift/domain/confidence.gleam b/src/night_shift/domain/confidence.gleam index 37e2f19..e715ebd 100644 --- a/src/night_shift/domain/confidence.gleam +++ b/src/night_shift/domain/confidence.gleam @@ -66,12 +66,13 @@ fn severe_reasons( |> list.filter(fn(task) { task.worktree_path != "" }) |> list.filter(fn(task) { !directory_exists(task.worktree_path) }) |> list.length - let payload_repair_failures = event_count(events, "execution_payload_repair_failed") + let payload_repair_failures = + event_count(events, "execution_payload_repair_failed") let run_failed = event_count(events, "run_failed") [ latest_environment_preflight_failure(events) - |> option_reason("Environment bootstrap failed."), + |> option_reason("Environment bootstrap failed."), count_reason( manual_attention_count, "manual-attention task is still unresolved.", @@ -93,7 +94,11 @@ fn severe_reasons( "retained worktree is missing from disk.", "retained worktrees are missing from disk.", ), - count_reason(run_failed, "run failure was recorded.", "run failures were recorded."), + count_reason( + run_failed, + "run failure was recorded.", + "run failures were recorded.", + ), ] |> list.filter_map(identity_reason) } @@ -103,7 +108,8 @@ fn moderate_reasons( repo_state_view: Option(repo_state_runtime.RepoStateView), ) -> List(String) { let payload_warnings = event_count(events, "execution_payload_warning") - let payload_repairs = event_count(events, "execution_payload_repair_succeeded") + let payload_repairs = + event_count(events, "execution_payload_repair_succeeded") let prune_warnings = event_count(events, "worktree_prune_warning") let supersession_warnings = event_count(events, "review_supersession_warning") let repo_state_reason = case repo_state_view { @@ -158,7 +164,7 @@ fn positive_reasons( retained_worktrees > 0 && list.all( run.tasks - |> list.filter(fn(task) { task.worktree_path != "" }), + |> list.filter(fn(task) { task.worktree_path != "" }), fn(task) { directory_exists(task.worktree_path) }, ) @@ -176,7 +182,8 @@ fn positive_reasons( False -> None }, case retained_and_present { - True -> Some("Retained worktrees remain mounted for inspection and recovery.") + True -> + Some("Retained worktrees remain mounted for inspection and recovery.") False -> None }, ] @@ -260,6 +267,7 @@ fn take_first_loop( case values, remaining <= 0 { _, True -> list.reverse(acc) [], False -> list.reverse(acc) - [value, ..rest], False -> take_first_loop(rest, remaining - 1, [value, ..acc]) + [value, ..rest], False -> + take_first_loop(rest, remaining - 1, [value, ..acc]) } } diff --git a/src/night_shift/domain/provenance.gleam b/src/night_shift/domain/provenance.gleam index 1cd43e3..5e4f6a5 100644 --- a/src/night_shift/domain/provenance.gleam +++ b/src/night_shift/domain/provenance.gleam @@ -23,7 +23,13 @@ pub fn write_persisted( let repo_state_view = None let assessment = confidence.assess(run, events, repo_state_view) let manifest = - manifest_json(run, events, repo_state_view, verification_commands, assessment) + manifest_json( + run, + events, + repo_state_view, + verification_commands, + assessment, + ) write_file(artifact_path(run), json.to_string(manifest)) } @@ -38,7 +44,8 @@ pub fn render( use filtered_tasks <- result.try(filter_tasks(run.tasks, task_filter)) let filtered_run = types.RunRecord(..run, tasks: filtered_tasks) let filtered_events = filter_events(events, task_filter) - let assessment = confidence.assess(filtered_run, filtered_events, repo_state_view) + let assessment = + confidence.assess(filtered_run, filtered_events, repo_state_view) Ok(case format { types.ProvenanceJson -> @@ -80,10 +87,13 @@ fn render_markdown( "- Confidence posture: " <> types.confidence_posture_to_string(assessment.posture), "- Confidence reasons: " <> confidence.reasons_summary(assessment), - "- Planning provenance: " <> render_planning_provenance(run.planning_provenance), + "- Planning provenance: " + <> render_planning_provenance(run.planning_provenance), "- Notes source: " <> render_notes_source(run.notes_source), - "- Planning artifacts: " <> render_string_list(planning_artifact_paths(run, events)), - "- Planner prompt: " <> render_optional_path(planner_prompt_path(run.run_path)), + "- Planning artifacts: " + <> render_string_list(planning_artifact_paths(run, events)), + "- Planner prompt: " + <> render_optional_path(planner_prompt_path(run.run_path)), "- Planner log: " <> render_optional_path(planner_log_path(run.run_path)), render_review_state_markdown(run, repo_state_view), "", @@ -117,9 +127,10 @@ fn manifest_json( #("provenance_path", json.string(artifact_path(run))), #("planning_agent", agent_json(run.planning_agent)), #("execution_agent", agent_json(run.execution_agent)), - #("planning_provenance", json.string(render_planning_provenance( - run.planning_provenance, - ))), + #( + "planning_provenance", + json.string(render_planning_provenance(run.planning_provenance)), + ), #("notes_source", json.string(render_notes_source(run.notes_source))), #( "planning_artifacts", @@ -127,7 +138,10 @@ fn manifest_json( ), #( "planner_prompt_path", - json.nullable(from: planner_prompt_path(run.run_path), of: json.string), + json.nullable( + from: planner_prompt_path(run.run_path), + of: json.string, + ), ), #( "planner_log_path", @@ -152,7 +166,10 @@ fn manifest_json( of: identity_json, ), ), - #("tasks", json.array(run.tasks, task_json(_, run, events, verification_commands))), + #( + "tasks", + json.array(run.tasks, task_json(_, run, events, verification_commands)), + ), #("event_refs", json.array(events, event_ref_json)), ]) } @@ -166,7 +183,8 @@ fn task_json( let relevant_events = events |> list.filter(fn(event) { event.task_id == Some(task.id) }) - let verification_log = existing_file(verification_log_path(run.run_path, task.id)) + let verification_log = + existing_file(verification_log_path(run.run_path, task.id)) json.object([ #("id", json.string(task.id)), @@ -176,34 +194,40 @@ fn task_json( #("worktree_path", json.string(task.worktree_path)), #("branch_name", json.string(task.branch_name)), #("pr_number", json.string(task.pr_number)), + #("superseded_pr_numbers", json.array(task.superseded_pr_numbers, json.int)), #( - "superseded_pr_numbers", - json.array(task.superseded_pr_numbers, json.int), + "files_touched", + json.array(parse_changed_files(task.summary), json.string), ), - #("files_touched", json.array(parse_changed_files(task.summary), json.string)), #( "verification", json.object([ #("commands", json.array(verification_commands, json.string)), - #( - "outcome", - json.string(verification_outcome(task, relevant_events)), - ), - #( - "log_path", - json.nullable(from: verification_log, of: json.string), - ), + #("outcome", json.string(verification_outcome(task, relevant_events))), + #("log_path", json.nullable(from: verification_log, of: json.string)), ]), ), #( "artifacts", json.object([ - #("prompt_paths", json.array(task_prompt_paths(run.run_path, task.id), json.string)), - #("log_paths", json.array(task_log_paths(run.run_path, task.id), json.string)), - #("raw_payload_paths", json.array(raw_payload_paths(run.run_path, task.id), json.string)), + #( + "prompt_paths", + json.array(task_prompt_paths(run.run_path, task.id), json.string), + ), + #( + "log_paths", + json.array(task_log_paths(run.run_path, task.id), json.string), + ), + #( + "raw_payload_paths", + json.array(raw_payload_paths(run.run_path, task.id), json.string), + ), #( "sanitized_payload_paths", - json.array(sanitized_payload_paths(run.run_path, task.id), json.string), + json.array( + sanitized_payload_paths(run.run_path, task.id), + json.string, + ), ), ]), ), @@ -218,25 +242,30 @@ fn review_state_json( case run.repo_state_snapshot { None -> None Some(snapshot) -> - Some(json.object([ - #("snapshot_captured_at", json.string(snapshot.captured_at)), - #("captured_open_pr_count", json.int(list.length(snapshot.open_pull_requests))), - #( - "captured_actionable_pr_count", - json.int( - snapshot.open_pull_requests - |> list.filter(fn(pr) { pr.actionable }) - |> list.length, + Some( + json.object([ + #("snapshot_captured_at", json.string(snapshot.captured_at)), + #( + "captured_open_pr_count", + json.int(list.length(snapshot.open_pull_requests)), ), - ), - #( - "drift", - json.string(case repo_state_view { - Some(view) -> repo_state_runtime.drift_label(view.drift) - None -> "unknown" - }), - ), - ])) + #( + "captured_actionable_pr_count", + json.int( + snapshot.open_pull_requests + |> list.filter(fn(pr) { pr.actionable }) + |> list.length, + ), + ), + #( + "drift", + json.string(case repo_state_view { + Some(view) -> repo_state_runtime.drift_label(view.drift) + None -> "unknown" + }), + ), + ]), + ) } } @@ -262,18 +291,24 @@ fn render_task_sections( " Branch: " <> render_empty_as_dash(task.branch_name), " PR: " <> render_empty_as_dash(task.pr_number), " Worktree: " <> render_empty_as_dash(task.worktree_path), - " Files touched: " <> render_string_list(parse_changed_files(task.summary)), - " Verification commands: " <> render_string_list(verification_commands), - " Verification outcome: " <> verification_outcome(task, relevant_events), - " Prompt artifacts: " <> render_string_list(task_prompt_paths(run.run_path, task.id)), - " Log artifacts: " <> render_string_list(task_log_paths(run.run_path, task.id)), - " Raw payloads: " <> render_string_list(raw_payload_paths(run.run_path, task.id)), + " Files touched: " + <> render_string_list(parse_changed_files(task.summary)), + " Verification commands: " + <> render_string_list(verification_commands), + " Verification outcome: " + <> verification_outcome(task, relevant_events), + " Prompt artifacts: " + <> render_string_list(task_prompt_paths(run.run_path, task.id)), + " Log artifacts: " + <> render_string_list(task_log_paths(run.run_path, task.id)), + " Raw payloads: " + <> render_string_list(raw_payload_paths(run.run_path, task.id)), " Sanitized payloads: " <> render_string_list(sanitized_payload_paths(run.run_path, task.id)), " Event refs: " <> render_string_list( - relevant_events |> list.map(render_event_ref_label), - ), + relevant_events |> list.map(render_event_ref_label), + ), ] |> string.join(with: "\n") }) @@ -410,7 +445,10 @@ fn task_log_paths(run_path: String, task_id: String) -> List(String) { 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"), + filepath.join( + run_path, + "logs/" <> task_id <> ".payload-repair.result.raw.jsonish", + ), ] |> existing_files } @@ -418,7 +456,10 @@ fn raw_payload_paths(run_path: String, task_id: String) -> List(String) { 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"), + filepath.join( + run_path, + "logs/" <> task_id <> ".payload-repair.result.sanitized.json", + ), ] |> existing_files } @@ -448,7 +489,9 @@ fn verification_outcome( False -> case task.state { types.Failed -> - case string.contains(does: task.summary, contain: "verification failed") { + case + string.contains(does: task.summary, contain: "verification failed") + { True -> "failed" False -> "not_recorded" } diff --git a/src/night_shift/infra/run_store.gleam b/src/night_shift/infra/run_store.gleam index 489538e..403033b 100644 --- a/src/night_shift/infra/run_store.gleam +++ b/src/night_shift/infra/run_store.gleam @@ -200,7 +200,10 @@ pub fn save( journal_codec.encode_run(run), )) use _ <- result.try(write_events(run.events_path, events)) - use _ <- result.try(write_string(run.report_path, report.render_persisted(run, events))) + use _ <- result.try(write_string( + run.report_path, + report.render_persisted(run, events), + )) provenance.write_persisted(run, events) } diff --git a/src/night_shift/usecase/doctor.gleam b/src/night_shift/usecase/doctor.gleam index 8c02acb..ebbe363 100644 --- a/src/night_shift/usecase/doctor.gleam +++ b/src/night_shift/usecase/doctor.gleam @@ -16,14 +16,21 @@ pub fn execute( config: types.Config, ) -> Result(String, String) { use #(run, events) <- result.try(journal.load(repo_root, selector)) - let repo_state_view = repo_state_runtime.inspect(run, config.branch_prefix).view + let repo_state_view = + repo_state_runtime.inspect(run, config.branch_prefix).view let active_lock = active_lock_state(repo_root, run.run_id) let assessments = run.tasks |> list.map(diagnose_task(repo_root, run.run_path, _)) let recommendation = recommend_next_action(run.status, events, active_lock, assessments) - Ok(render_doctor(run, repo_state_view, active_lock, recommendation, assessments)) + Ok(render_doctor( + run, + repo_state_view, + active_lock, + recommendation, + assessments, + )) } type ActiveLockState { @@ -113,11 +120,10 @@ fn diagnose_task( ) -> TaskAssessment { let git_log = filepath.join(run_path, "logs/" <> task.id <> ".doctor.git.log") let execution_log = filepath.join(run_path, "logs/" <> task.id <> ".log") - let worktree_exists = - case task.worktree_path { - "" -> False - path -> directory_exists(path) - } + let worktree_exists = case task.worktree_path { + "" -> False + path -> directory_exists(path) + } let mounted_worktree = case task.branch_name { "" -> Ok(None) _ -> git.mounted_worktree_path(repo_root, task.branch_name, git_log) @@ -125,19 +131,13 @@ fn diagnose_task( case task.state { types.Completed -> - TaskAssessment( - task: task, - classification: types.SafeToResume, - reasons: [ - "Task is already completed and does not need recovery work.", - ], - ) + TaskAssessment(task: task, classification: types.SafeToResume, reasons: [ + "Task is already completed and does not need recovery work.", + ]) types.Ready | types.Queued -> - TaskAssessment( - task: task, - classification: types.SafeToResume, - reasons: ["Task has not started yet; resume would schedule it normally."], - ) + TaskAssessment(task: task, classification: types.SafeToResume, reasons: [ + "Task has not started yet; resume would schedule it normally.", + ]) types.Blocked | types.ManualAttention -> TaskAssessment( task: task, @@ -193,11 +193,11 @@ fn diagnose_running_task( ) True -> { let doctor_git_log = - filepath.join(run_path, "logs/" <> task.id <> ".doctor.has-changes.log") - case git.has_changes( - task.worktree_path, - doctor_git_log, - ) { + filepath.join( + run_path, + "logs/" <> task.id <> ".doctor.has-changes.log", + ) + case git.has_changes(task.worktree_path, doctor_git_log) { True -> TaskAssessment( task: task, @@ -289,10 +289,9 @@ fn recommend_next_action( True -> "At least one task is irrecoverable from saved state; inspect the journal and replan rather than resuming." False -> - case has_classification( - assessments, - types.RecoveryManualAttention, - ) { + case + has_classification(assessments, types.RecoveryManualAttention) + { True -> "Resolve the manual-attention tasks first; `resume` would not safely clear them." False -> @@ -302,10 +301,9 @@ fn recommend_next_action( <> other_run_id <> "); clear that ambiguity before resuming." _ -> - case has_classification( - assessments, - types.ResumeWithWarning, - ) { + case + has_classification(assessments, types.ResumeWithWarning) + { True -> "Resume is possible, but review the warnings above before you let Night Shift continue." False -> @@ -322,9 +320,7 @@ fn has_classification( assessments: List(TaskAssessment), target: types.RecoveryClassification, ) -> Bool { - list.any(assessments, fn(assessment) { - assessment.classification == target - }) + list.any(assessments, fn(assessment) { assessment.classification == target }) } fn latest_environment_preflight_failure( diff --git a/src/night_shift/usecase/provenance.gleam b/src/night_shift/usecase/provenance.gleam index 1e01f50..a597ee7 100644 --- a/src/night_shift/usecase/provenance.gleam +++ b/src/night_shift/usecase/provenance.gleam @@ -1,5 +1,5 @@ -import gleam/result import gleam/option.{type Option} +import gleam/result import night_shift/domain/provenance as provenance_domain import night_shift/journal import night_shift/repo_state_runtime @@ -13,7 +13,8 @@ pub fn execute( config: types.Config, ) -> Result(String, String) { use #(run, events) <- result.try(journal.load(repo_root, selector)) - let repo_state_view = repo_state_runtime.inspect(run, config.branch_prefix).view + let repo_state_view = + repo_state_runtime.inspect(run, config.branch_prefix).view provenance_domain.render( run, events, diff --git a/src/night_shift/usecase/resume.gleam b/src/night_shift/usecase/resume.gleam index e583cc6..c88766c 100644 --- a/src/night_shift/usecase/resume.gleam +++ b/src/night_shift/usecase/resume.gleam @@ -39,7 +39,9 @@ pub fn execute( )) } -pub fn prepare_resumed_run(run: types.RunRecord) -> Result(types.RunRecord, String) { +pub fn prepare_resumed_run( + run: types.RunRecord, +) -> Result(types.RunRecord, String) { let resumed_tasks = run.tasks |> list.map(fn(task) { recover_task(run.run_path, task) }) @@ -63,7 +65,10 @@ fn recover_task(run_path: String, task: types.Task) -> types.Task { worktree_path -> git.has_changes( worktree_path, - filepath.join(run_path, "logs/" <> task.id <> ".recover.has-changes.log"), + filepath.join( + run_path, + "logs/" <> task.id <> ".recover.has-changes.log", + ), ) } diff --git a/src/night_shift/usecase/status.gleam b/src/night_shift/usecase/status.gleam index ab7c82e..5f181a0 100644 --- a/src/night_shift/usecase/status.gleam +++ b/src/night_shift/usecase/status.gleam @@ -15,8 +15,7 @@ pub fn execute( use #(run, events) <- result.try(runs.load_display_run(repo_root, selector)) let next_action = runs.next_action_for_run(run) let inspection = repo_state_runtime.inspect(run, config.branch_prefix) - let confidence_assessment = - confidence.assess(run, events, inspection.view) + let confidence_assessment = confidence.assess(run, events, inspection.view) Ok(workflow.StatusResult( run: run, events: events, diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index f683594..59ab55a 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -104,8 +104,7 @@ pub fn parse_provenance_command_test() { types.LatestRun, Some("task-1"), types.ProvenanceJson, - )) = - cli.parse(["provenance", "--task", "task-1", "--format", "json"]) + )) = cli.parse(["provenance", "--task", "task-1", "--format", "json"]) } pub fn parse_resume_rejects_environment_flag_test() { diff --git a/test/night_shift_resume_test.gleam b/test/night_shift_resume_test.gleam index b2701dd..3273b37 100644 --- a/test/night_shift_resume_test.gleam +++ b/test/night_shift_resume_test.gleam @@ -61,8 +61,8 @@ pub fn resume_keeps_clean_interrupted_worktree_requeueable_test() { assert task.state == types.Ready assert task.summary == "" - assert - git.changed_files(worktree_path, filepath.join(base_dir, "status.log")) == [] + assert git.changed_files(worktree_path, filepath.join(base_dir, "status.log")) + == [] let assert Error(_) = simplifile.read(filepath.join(worktree_path, ".night-shift-recover.log")) diff --git a/test/trust_surface_test.gleam b/test/trust_surface_test.gleam index a9059ad..f890030 100644 --- a/test/trust_surface_test.gleam +++ b/test/trust_surface_test.gleam @@ -6,8 +6,8 @@ import night_shift/domain/provenance as provenance_domain import night_shift/domain/repo_state import night_shift/git import night_shift/journal -import night_shift/report import night_shift/repo_state_runtime +import night_shift/report import night_shift/shell import night_shift/system import night_shift/types @@ -48,7 +48,10 @@ pub fn report_includes_confidence_and_provenance_test() { assert string.contains(does: rendered, contain: "Confidence posture:") assert string.contains(does: rendered, contain: "Confidence reasons:") - assert string.contains(does: rendered, contain: "Provenance: /tmp/repo/.night-shift/runs/review-run/provenance.json") + assert string.contains( + does: rendered, + contain: "Provenance: /tmp/repo/.night-shift/runs/review-run/provenance.json", + ) } pub fn provenance_render_includes_review_drift_test() { @@ -121,53 +124,56 @@ pub fn doctor_flags_dirty_and_missing_worktrees_test() { let assert Ok(_) = simplifile.write("dirty\n", to: filepath.join(repo_root, "DIRTY.md")) let updated_run = - types.RunRecord( - ..run, - tasks: [ - types.Task( - id: "dirty-task", - title: "Dirty task", - description: "", - dependencies: [], - acceptance: [], - demo_plan: [], - decision_requests: [], - superseded_pr_numbers: [], - kind: types.ImplementationTask, - execution_mode: types.Serial, - state: types.Running, - worktree_path: repo_root, - branch_name: "night-shift/dirty-task", - pr_number: "", - summary: "", - runtime_context: None, - ), - types.Task( - id: "missing-task", - title: "Missing task", - description: "", - dependencies: [], - acceptance: [], - demo_plan: [], - decision_requests: [], - superseded_pr_numbers: [], - kind: types.ImplementationTask, - execution_mode: types.Serial, - state: types.Running, - worktree_path: missing_worktree, - branch_name: "night-shift/missing-task", - pr_number: "", - summary: "", - runtime_context: None, - ), - ], - ) + types.RunRecord(..run, tasks: [ + types.Task( + id: "dirty-task", + title: "Dirty task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: repo_root, + branch_name: "night-shift/dirty-task", + pr_number: "", + summary: "", + runtime_context: None, + ), + types.Task( + id: "missing-task", + title: "Missing task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: missing_worktree, + branch_name: "night-shift/missing-task", + pr_number: "", + summary: "", + runtime_context: None, + ), + ]) let assert Ok(_) = journal.rewrite_run(updated_run) let assert Ok(rendered) = doctor.execute(repo_root, types.LatestRun, types.default_config()) - assert string.contains(does: rendered, contain: "[manual_attention] Dirty task") - assert string.contains(does: rendered, contain: "[irrecoverable] Missing task") + assert string.contains( + does: rendered, + contain: "[manual_attention] Dirty task", + ) + assert string.contains( + does: rendered, + contain: "[irrecoverable] Missing task", + ) let _ = simplifile.delete(file_or_dir_at: base_dir) } @@ -199,34 +205,34 @@ pub fn doctor_does_not_write_probe_log_into_worktree_test() { ) let assert Ok(run) = support.start_run(repo_root, brief_path, types.Codex, 1) let updated_run = - types.RunRecord( - ..run, - tasks: [ - types.Task( - id: "clean-task", - title: "Clean task", - description: "", - dependencies: [], - acceptance: [], - demo_plan: [], - decision_requests: [], - superseded_pr_numbers: [], - kind: types.ImplementationTask, - execution_mode: types.Serial, - state: types.Running, - worktree_path: worktree_path, - branch_name: "night-shift/clean-task", - pr_number: "", - summary: "", - runtime_context: None, - ), - ], - ) + types.RunRecord(..run, tasks: [ + types.Task( + id: "clean-task", + title: "Clean task", + description: "", + dependencies: [], + acceptance: [], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Running, + worktree_path: worktree_path, + branch_name: "night-shift/clean-task", + pr_number: "", + summary: "", + runtime_context: None, + ), + ]) let assert Ok(_) = journal.rewrite_run(updated_run) let assert Ok(rendered) = doctor.execute(repo_root, types.LatestRun, types.default_config()) - assert string.contains(does: rendered, contain: "[resume_with_warning] Clean task") + assert string.contains( + does: rendered, + contain: "[resume_with_warning] Clean task", + ) let assert Error(_) = simplifile.read(probe_path) let _ =