From 112066cb97bd16a04e055617eb326138210b19b0 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:26:30 -0700 Subject: [PATCH 1/3] 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 4fa6de9e3ddc7555e0cb489d2fab05b1657080b5 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 15:43:37 -0700 Subject: [PATCH 2/3] 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 78706191406fda53f9bfc03828d1f3dab2efc789 Mon Sep 17 00:00:00 2001 From: William Pelrine Date: Mon, 13 Apr 2026 16:00:00 -0700 Subject: [PATCH 3/3] 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 -- \"$@\"", )