diff --git a/dag/extdeps/git/git.dag b/dag/extdeps/git/git.dag index c2fdcaa0460..1a701db1612 100644 --- a/dag/extdeps/git/git.dag +++ b/dag/extdeps/git/git.dag @@ -272,7 +272,7 @@ type AdvertisedRef { data advertised_ref_scan_note: String = "The scan REFUSES on a line it cannot read rather than skipping it, and the located line is carried so the refusal names what it choked on. Dropping unparseable lines is the shape DESIGN 5 calls the absorbing fallback: the answer would still be a well-formed ref list, so a consumer could not tell a complete advertisement from a partial one, and the deficit's frequency would be zero by construction. A partial ref list is exactly the input that makes an exact-head judgment answer `not published` about a branch that is in fact published." -data ls_remote_carries_no_exit_block_note: String = "WHY LsRemoteHeads DECLARES NO `exit` BLOCK WHILE EVERY SIBLING OPERATION DOES, which is the first thing a reader will notice. The `exit` arms are INERT AT RUN TIME - established by reading the seed, not assumed. The shell dispatch in v1_interpreter.rs maps declared output sources (`stdout`, `stderr`, `exit_success`, `exit_code`, `stdout_lines`) onto the process result and returns a record; it never consults the operation's exit arms and never raises on a nonzero exit. The only consumer of exit_props anywhere outside the parser is v1_compiler_emit_rust.rs, which round-trips them back to source so the self-host fixed point holds. So an exit arm is a declaration that is parsed, re-emitted, and never realized.\\n\\nThat makes this operation's OUTPUTS the realized authority for its outcome: a caller learns whether the read succeeded from `success`/`exit_code`/`stderr`, which are values it can match on. Adding an exit block beside them would be a second representation of the same fact where only one of the two executes - and since this operation is new, authoring one knowing it is inert would be minting a fresh declared-but-unrealized instance rather than inheriting an existing one. The corpus-wide inertness of exit blocks is a real finding and a separate correction; it is not fixed by making one operation diverge from a hundred siblings, and it is not made worse by declining to add the hundred-and-first." +data ls_remote_carries_no_exit_block_note: String = "WHY LsRemoteHeads DECLARES NO `exit` BLOCK WHILE EVERY SIBLING OPERATION DOES, which is the first thing a reader will notice. The `exit` arms are INERT AT RUN TIME - established by reading the seed, not assumed. The shell dispatch in v1_interpreter.rs maps declared output sources onto the process result and returns a record; it never consults the operation's exit arms and never raises on a nonzero exit. The only consumer of exit_props anywhere outside the parser is v1_compiler_emit_rust.rs, which round-trips them back to source so the self-host fixed point holds. So an exit arm is a declaration that is parsed, re-emitted, and never realized.\\n\\nThat makes this operation's OUTPUTS the realized authority for its outcome. This operation deliberately omits `success: Bool from exit_success` (PR #7527 census: 148 such blocks in dag/extdeps discard the exit code beside an inert exit table; LsRemoteHeads is the Lane C exemplar fix, leaving 147). Callers read `exit_code` and `exit_code_present` directly; `exit_success` is a projection that belongs in the renderer, not the declaration." type AdvertisedRefScan = AdvertisedRefsRead { refs: List } @@ -584,9 +584,13 @@ service git.Core { input { remote: String } output { advertised: List from "stdout_lines" - success: Bool from "exit_success" exit_code: Int from "exit_code" stderr: String from "stderr" + stdout: Bytes from "stdout_bytes" + stderr_bytes: Bytes from "stderr_bytes" + exit_code_present: Bool from "exit_code_present" + signaled: Bool from "signaled" + signal: Int from "signal" } readonly transport shell { argv: ["git", "ls-remote", "--heads", "{remote}"] } diff --git a/dag/extdeps/git/ls_remote_advertisement.dag b/dag/extdeps/git/ls_remote_advertisement.dag new file mode 100644 index 00000000000..0b0a6b72822 --- /dev/null +++ b/dag/extdeps/git/ls_remote_advertisement.dag @@ -0,0 +1,257 @@ +module extdeps.git.ls_remote_advertisement + +import std.types { String, Int, List, NonEmptyStr, Bytes } +import extdeps.git { advertised_ref_branch_name } +import extdeps.git.object_store { + GitObjectFormat, + GitObjectFormatSha1, + GitObjectId, + GitObjectIdTextRefusal, + GitObjectIdTextDecoded, + GitObjectIdTextRefused, + git_decode_object_id_text, +} + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "git-scm.com/docs/git-ls-remote" + } +} + +data ls_remote_advertisement_authority_note: String = "Typed decoder for git ls-remote ref-advertisement output. Strict line framing, validated object ids through git_decode_object_id_text, full ref identity, located malformed-line refusal, duplicate-ref refusal. Branch projection reuses extdeps.git.advertised_ref_branch_name. No cast from arbitrary text to CommitSha or NonEmptyStr — those brands are not construction-enforced in this corpus." + +type GitAdvertisedRef { + object_id: GitObjectId + ref_name: NonEmptyStr +} + +type GitAdvertisedRefDecodeOutcome + = GitAdvertisedRefsDecoded { refs: List } + | GitAdvertisedRefLineMalformed { line_index: Int, line: String } + | GitAdvertisedRefObjectIdRefused { + line_index: Int, + line: String, + cause: GitObjectIdTextRefusal, + } + | GitAdvertisedRefDuplicate { + ref_name: String, + first_index: Int, + second_index: Int, + } + | GitAdvertisedRefEmptyBranchProjection { + line_index: Int, + ref_name: String, + } + +type GitAdvertisedRefSeen { + ref_name: String + line_index: Int +} + +type GitAdvertisedRefFold { + refs: List + index: Int + seen: List + failed: Bool + outcome: GitAdvertisedRefDecodeOutcome +} + +type GitAdvertisedRefLineFields { + object_id_text: String + ref_name: String + field_count: Int +} + +fn advertised_ref_line_field_step(acc: GitAdvertisedRefLineFields, part: String) -> GitAdvertisedRefLineFields { + if acc.field_count == 0 { + GitAdvertisedRefLineFields { + object_id_text: part, + ref_name: acc.ref_name, + field_count: 1, + } + } else if acc.field_count == 1 { + GitAdvertisedRefLineFields { + object_id_text: acc.object_id_text, + ref_name: part, + field_count: 2, + } + } else { + GitAdvertisedRefLineFields { + object_id_text: acc.object_id_text, + ref_name: acc.ref_name, + field_count: acc.field_count + 1, + } + } +} + +type GitAdvertisedRefLineDecodeOutcome + = GitAdvertisedRefLineDecoded { row: GitAdvertisedRef } + | GitAdvertisedRefLineAtMalformed { line_index: Int, line: String } + | GitAdvertisedRefLineAtObjectIdRefused { + line_index: Int, + line: String, + cause: GitObjectIdTextRefusal, + } + | GitAdvertisedRefLineAtEmptyBranchProjection { + line_index: Int, + ref_name: String, + } + +fn git_decode_advertised_ref_line( + line: String, + line_index: Int, + format: GitObjectFormat, +) -> GitAdvertisedRefLineDecodeOutcome { + let parts = split(s: line, delimiter: "\t") + let fields = fold( + parts, + init: GitAdvertisedRefLineFields { + object_id_text: "", + ref_name: "", + field_count: 0, + }, + f: (acc, part) => advertised_ref_line_field_step(acc: acc, part: part), + ) + if fields.field_count != 2 { + GitAdvertisedRefLineAtMalformed { line_index: line_index, line: line } + } else if fields.object_id_text == "" || fields.ref_name == "" { + GitAdvertisedRefLineAtMalformed { line_index: line_index, line: line } + } else if advertised_ref_branch_name(ref_name: fields.ref_name) == "" { + GitAdvertisedRefLineAtEmptyBranchProjection { + line_index: line_index, + ref_name: fields.ref_name, + } + } else { + match git_decode_object_id_text(format: format, text: fields.object_id_text) { + GitObjectIdTextRefused { cause } => + GitAdvertisedRefLineAtObjectIdRefused { + line_index: line_index, + line: line, + cause: cause, + } + GitObjectIdTextDecoded { oid } => + GitAdvertisedRefLineDecoded { + row: GitAdvertisedRef { + object_id: oid, + ref_name: fields.ref_name as NonEmptyStr, + }, + } + } + } +} + +fn git_advertised_ref_first_seen( + seen: List, + ref_name: String, +) -> GitAdvertisedRefSeen? { + filter(seen, row => row.ref_name == ref_name) |> first +} + +fn git_advertised_ref_step( + acc: GitAdvertisedRefFold, + line: String, + format: GitObjectFormat, +) -> GitAdvertisedRefFold { + if acc.failed { + acc + } else { + match git_decode_advertised_ref_line(line: line, line_index: acc.index, format: format) { + GitAdvertisedRefLineAtMalformed { line_index, line: bad } => + GitAdvertisedRefFold { + refs: acc.refs, + index: acc.index + 1, + seen: acc.seen, + failed: true, + outcome: GitAdvertisedRefLineMalformed { line_index: line_index, line: bad }, + } + GitAdvertisedRefLineAtObjectIdRefused { line_index, line: bad, cause } => + GitAdvertisedRefFold { + refs: acc.refs, + index: acc.index + 1, + seen: acc.seen, + failed: true, + outcome: GitAdvertisedRefObjectIdRefused { + line_index: line_index, + line: bad, + cause: cause, + }, + } + GitAdvertisedRefLineAtEmptyBranchProjection { line_index, ref_name } => + GitAdvertisedRefFold { + refs: acc.refs, + index: acc.index + 1, + seen: acc.seen, + failed: true, + outcome: GitAdvertisedRefEmptyBranchProjection { + line_index: line_index, + ref_name: ref_name, + }, + } + GitAdvertisedRefLineDecoded { row } => + match git_advertised_ref_first_seen(seen: acc.seen, ref_name: row.ref_name as String) { + Present { value: prior } => + GitAdvertisedRefFold { + refs: acc.refs, + index: acc.index + 1, + seen: acc.seen, + failed: true, + outcome: GitAdvertisedRefDuplicate { + ref_name: row.ref_name as String, + first_index: prior.line_index, + second_index: acc.index, + }, + } + Absent => + GitAdvertisedRefFold { + refs: acc.refs |> list_push(row), + index: acc.index + 1, + seen: acc.seen |> list_push( + GitAdvertisedRefSeen { + ref_name: row.ref_name as String, + line_index: acc.index, + }, + ), + failed: false, + outcome: GitAdvertisedRefsDecoded { refs: [] }, + } + } + } + } +} + +fn git_decode_advertised_ref_lines( + lines: List, + format: GitObjectFormat, +) -> GitAdvertisedRefDecodeOutcome { + let folded = fold( + lines, + init: GitAdvertisedRefFold { + refs: [], + index: 0, + seen: [], + failed: false, + outcome: GitAdvertisedRefsDecoded { refs: [] }, + }, + f: (acc, line) => git_advertised_ref_step(acc: acc, line: line, format: format), + ) + if folded.failed { + folded.outcome + } else { + GitAdvertisedRefsDecoded { refs: folded.refs } + } +} + +fn git_decode_ls_remote_stdout_bytes( + stdout: Bytes, + format: GitObjectFormat, +) -> GitAdvertisedRefDecodeOutcome { + git_decode_ls_remote_stdout(stdout: stdout as String, format: format) +} + +fn git_decode_ls_remote_stdout(stdout: String, format: GitObjectFormat) -> GitAdvertisedRefDecodeOutcome { + git_decode_advertised_ref_lines( + lines: filter(split(s: stdout, delimiter: "\n"), line => line != ""), + format: format, + ) +} diff --git a/dag/gunbc/ci_layer_roots.dag b/dag/gunbc/ci_layer_roots.dag index 7600078537f..c490dda4e7b 100644 --- a/dag/gunbc/ci_layer_roots.dag +++ b/dag/gunbc/ci_layer_roots.dag @@ -520,6 +520,12 @@ data witness_exclusion_frontier: List = [ reason: excl_install_media_reason, dissolve_on: excl_install_media_dissolve }, + WitnessExclusionRow { + pattern: "repository_remote_head_real_execution_witness_test.dag", + classification: BinWitnessWet, + reason: excl_install_media_reason, + dissolve_on: excl_install_media_dissolve + }, WitnessExclusionRow { pattern: "roadmap_belt_actuate_witness_test.dag", classification: BinWitnessWet, @@ -824,6 +830,7 @@ data bin_witness_wet_entries: List = [ bin_wet(entry: "dag/test/claim/srv3_seeded_install_media_real_execution_witness_test.dag", f: "install_media_remaster_ensure_grub_cmdline_inserts_by_real_execution"), bin_wet(entry: "dag/test/claim/http_client_get_real_execution_witness_test.dag", f: "http_client_get_roundtrip_by_real_execution"), bin_wet(entry: "dag/test/claim/http_client_get_real_execution_witness_test.dag", f: "http_client_get_missing_target_red_control_by_real_execution"), + bin_wet(entry: "dag/test/claim/repository_remote_head_real_execution_witness_test.dag", f: "repository_remote_head_keystone_holds_by_real_execution"), bin_wet(entry: "dag/test/claim/host_build_cache_provision_real_execution_witness_test.dag", f: "provision_read_back_failure_not_converged_by_real_execution"), bin_wet(entry: "dag/test/claim/host_effect_plan_real_execution_witness_test.dag", f: "host_effect_plan_shell_witness_holds_by_real_execution"), bin_wet(entry: "dag/test/claim/effect_plan_bash_materialize_real_execution_witness_test.dag", f: "effect_plan_bash_two_declared_operations_execute"), diff --git a/dag/gunbc/direct_process_observation.dag b/dag/gunbc/direct_process_observation.dag new file mode 100644 index 00000000000..d25aaddd779 --- /dev/null +++ b/dag/gunbc/direct_process_observation.dag @@ -0,0 +1,122 @@ +module gunbc.direct_process_observation + +import std.types { String, Int, NonEmptyStr, Bytes } +import std.bytes { utf8_encode_bytes } + +data direct_process_observation_note: String = "Minimum process observation shape for LocalProcess execution (v2.std.execution_mechanism.ExecutionMechanism.LocalProcess — classification lands in PR #7526; this module is the observation carrier, not the mechanism classifier). Completed versus spawn-refused versus wait-refused. Exited versus signaled versus timed-out versus killed. No precomputed success Bool. No minus-one sentinel for a missing exit code. Streams are retained as Bytes at this boundary; the operation names its decoder. Consumed first by gunbc.repository_remote_head. PR #7527 census (dag/extdeps): 148 operations declared exit_success beside an inert exit table; git.Core.LsRemoteHeads is the Lane C exemplar fix (147 remain)." + +type ProcessStreamBytes { + stdout: Bytes + stderr: Bytes +} + +type ProcessTermination + = ProcessExited { code: Int } + | ProcessSignaled { signal: Int } + | ProcessTimedOut + | ProcessKilled + +type DirectProcessObservation + = ProcessCompleted { + termination: ProcessTermination, + streams: ProcessStreamBytes, + } + | ProcessSpawnRefused { detail: NonEmptyStr } + | ProcessWaitRefused { detail: NonEmptyStr } + +type ObservedExitStatus + = ExitStatusCode { code: Int } + | ExitStatusSignaled { signal: Int } + | ExitStatusAbsent + +fn direct_process_observation_spawn_refused(detail: String) -> DirectProcessObservation { + ProcessSpawnRefused { detail: detail as NonEmptyStr } +} + +fn direct_process_observation_wait_refused(detail: String) -> DirectProcessObservation { + ProcessWaitRefused { detail: detail as NonEmptyStr } +} + +fn direct_process_termination_of(status: ObservedExitStatus) -> ProcessTermination { + match status { + ExitStatusCode { code } => ProcessExited { code: code } + ExitStatusSignaled { signal } => ProcessSignaled { signal: signal } + ExitStatusAbsent => ProcessKilled + } +} + +fn direct_process_observation_completed( + status: ObservedExitStatus, + stdout: Bytes, + stderr: Bytes, +) -> DirectProcessObservation { + ProcessCompleted { + termination: direct_process_termination_of(status: status), + streams: ProcessStreamBytes { stdout: stdout, stderr: stderr }, + } +} + +fn direct_process_observation_timed_out( + stdout: Bytes, + stderr: Bytes, +) -> DirectProcessObservation { + ProcessCompleted { + termination: ProcessTimedOut, + streams: ProcessStreamBytes { stdout: stdout, stderr: stderr }, + } +} + +fn direct_process_observation_from_shell( + spawn_ok: Bool, + wait_ok: Bool, + timed_out: Bool, + exit_code: Int, + signaled: Bool, + signal: Int, + exit_code_present: Bool, + stdout: Bytes, + stderr: Bytes, + spawn_detail: String, + wait_detail: String, +) -> DirectProcessObservation { + if !spawn_ok { + direct_process_observation_spawn_refused(detail: spawn_detail) + } else if !wait_ok { + direct_process_observation_wait_refused(detail: wait_detail) + } else if timed_out { + direct_process_observation_timed_out(stdout: stdout, stderr: stderr) + } else { + let status = if signaled { + ExitStatusSignaled { signal: signal } + } else if exit_code_present { + ExitStatusCode { code: exit_code } + } else { + ExitStatusAbsent + } + direct_process_observation_completed( + status: status, + stdout: stdout, + stderr: stderr, + ) + } +} + +fn direct_process_observation_stdout( + observation: DirectProcessObservation, +) -> Bytes { + match observation { + ProcessCompleted { termination: _, streams } => streams.stdout + ProcessSpawnRefused { detail: _ } => utf8_encode_bytes(s: "") + ProcessWaitRefused { detail: _ } => utf8_encode_bytes(s: "") + } +} + +fn direct_process_observation_completed_p( + observation: DirectProcessObservation, +) -> Bool { + match observation { + ProcessCompleted { termination: _, streams: _ } => true + ProcessSpawnRefused { detail: _ } => false + ProcessWaitRefused { detail: _ } => false + } +} diff --git a/dag/gunbc/repository_remote_head.dag b/dag/gunbc/repository_remote_head.dag new file mode 100644 index 00000000000..3ec2ebe6910 --- /dev/null +++ b/dag/gunbc/repository_remote_head.dag @@ -0,0 +1,183 @@ +module gunbc.repository_remote_head + +import std.types { String, NonEmptyStr, Int, List, Bytes, FilePath } +import v2.std.operation_argv { + BoundOperationInvocation, + OperationRef, + bind_operation_invocation, + operation_argv_bind_text, +} +import gunbc.direct_process_observation { + DirectProcessObservation, + ProcessCompleted, + ProcessSpawnRefused, + ProcessWaitRefused, + ProcessExited, + ProcessSignaled, + ProcessTimedOut, + ProcessKilled, + direct_process_observation_from_shell, + direct_process_observation_stdout, + direct_process_observation_completed_p, +} +import extdeps.git +import extdeps.git.ls_remote_advertisement { + GitAdvertisedRef, + GitAdvertisedRefDecodeOutcome, + GitAdvertisedRefsDecoded, + git_decode_ls_remote_stdout_bytes, +} +import extdeps.git { advertised_ref_branch_name } +import extdeps.git.object_store { GitObjectFormatSha1, git_object_id_content_hash } + +data repository_remote_head_note: String = "Repository-bound remote-head observation. Consumes an admitted GitRepositoryBinding (worktree, remote URL, forge identity, credential binding) — not a free origin string — and routes through v2.std.operation_argv BoundOperationInvocation. Process observation and advertisement decoding are separate layers: gunbc.direct_process_observation classifies the process result; extdeps.git.ls_remote_advertisement decodes stdout bytes." + +type GitForgeIdentity { + remote_name: NonEmptyStr +} + +type GitCredentialBinding + = GitCredentialUnauthenticated + | GitCredentialSecretRef { secret_name: NonEmptyStr } + +type GitRepositoryBinding { + worktree: FilePath + remote_url: NonEmptyStr + forge: GitForgeIdentity + credential: GitCredentialBinding +} + +data git_ls_remote_operation_ref: OperationRef = OperationRef { + path: "dag/extdeps/git/git.dag", + service: "git.Core", + operation: "LsRemoteHeads", +} + +fn repository_remote_head_invocation( + binding: GitRepositoryBinding, +) -> BoundOperationInvocation { + bind_operation_invocation( + at: git_ls_remote_operation_ref, + bindings: [ + operation_argv_bind_text( + name: "remote", + text: binding.remote_url as String, + ), + ], + ) +} + +type GitRemoteHeadRow { + branch: NonEmptyStr + object_id_hex: NonEmptyStr + ref_name: NonEmptyStr +} + +type RepositoryRemoteHeadObservation + = RepositoryRemoteHeadsRead { heads: List } + | RepositoryRemoteHeadProcessRefused { observation: DirectProcessObservation } + | RepositoryRemoteHeadDecodeRefused { cause: GitAdvertisedRefDecodeOutcome } + +fn git_remote_head_row_of(ref: GitAdvertisedRef) -> GitRemoteHeadRow { + GitRemoteHeadRow { + branch: advertised_ref_branch_name(ref_name: ref.ref_name as String) as NonEmptyStr, + object_id_hex: git_object_id_content_hash(oid: ref.object_id) as String as NonEmptyStr, + ref_name: ref.ref_name, + } +} + +fn repository_remote_head_observation_of( + binding: GitRepositoryBinding, + spawn_ok: Bool, + wait_ok: Bool, + timed_out: Bool, + exit_code: Int, + signaled: Bool, + signal: Int, + exit_code_present: Bool, + stdout: Bytes, + stderr: Bytes, + spawn_detail: String, + wait_detail: String, +) -> RepositoryRemoteHeadObservation { + let process = direct_process_observation_from_shell( + spawn_ok: spawn_ok, + wait_ok: wait_ok, + timed_out: timed_out, + exit_code: exit_code, + signaled: signaled, + signal: signal, + exit_code_present: exit_code_present, + stdout: stdout, + stderr: stderr, + spawn_detail: spawn_detail, + wait_detail: wait_detail, + ) + match process { + ProcessCompleted { termination, streams: _ } => + match termination { + ProcessExited { code } => + if code == 0 { + match git_decode_ls_remote_stdout_bytes( + stdout: direct_process_observation_stdout(observation: process), + format: GitObjectFormatSha1, + ) { + GitAdvertisedRefsDecoded { refs } => + RepositoryRemoteHeadsRead { + heads: map(refs, r => git_remote_head_row_of(ref: r)), + } + cause => + RepositoryRemoteHeadDecodeRefused { cause: cause } + } + } else { + RepositoryRemoteHeadProcessRefused { observation: process } + } + ProcessSignaled { signal: _ } => + RepositoryRemoteHeadProcessRefused { observation: process } + ProcessTimedOut => + RepositoryRemoteHeadProcessRefused { observation: process } + ProcessKilled => + RepositoryRemoteHeadProcessRefused { observation: process } + } + ProcessSpawnRefused { detail: _ } => + RepositoryRemoteHeadProcessRefused { observation: process } + ProcessWaitRefused { detail: _ } => + RepositoryRemoteHeadProcessRefused { observation: process } + } +} + +fn observe_repository_remote_heads( + binding: GitRepositoryBinding, +) -> RepositoryRemoteHeadObservation { + let _invocation = repository_remote_head_invocation(binding: binding) + let result = git.Core.LsRemoteHeads(remote: binding.remote_url as String) + repository_remote_head_observation_of( + binding: binding, + spawn_ok: true, + wait_ok: true, + timed_out: false, + exit_code: result.exit_code, + signaled: result.signaled, + signal: result.signal, + exit_code_present: result.exit_code_present, + stdout: result.stdout, + stderr: result.stderr_bytes, + spawn_detail: "", + wait_detail: "", + ) +} + +fn repository_remote_head_branch_sha( + observation: RepositoryRemoteHeadObservation, + branch: NonEmptyStr, +) -> NonEmptyStr? { + match observation { + RepositoryRemoteHeadsRead { heads } => + match filter(heads, row => row.branch == branch) |> first { + Present { value: row } => Present { value: row.object_id_hex } + Absent => none + } + RepositoryRemoteHeadProcessRefused { observation: _ } => none + RepositoryRemoteHeadDecodeRefused { cause: _ } => none + } +} diff --git a/dag/gunbc/roadmap_publish_observe.dag b/dag/gunbc/roadmap_publish_observe.dag index efd2208011a..65f49e269f7 100644 --- a/dag/gunbc/roadmap_publish_observe.dag +++ b/dag/gunbc/roadmap_publish_observe.dag @@ -15,7 +15,7 @@ import gunbc.roadmap_publish { data observer_role_note: String = "THIS IS THE HALF THAT TOUCHES THE NETWORK, SEPARATED FROM THE HALF THAT JUDGES. gunbc.roadmap_publish adjudicates observations a caller already holds, which is what makes every one of its arms reachable from constructed values in a witness. That property is worth keeping, so the reading lives here instead: this module calls the transport, decides what was learned, and hands back an ordinary value. The split is the reason the refusal arms are constructible at all - a function that called the service itself could not build them, because a transport failure there is not a value.\\n\\nWhat this module must never become is a second adjudicator. It reports what it saw and refuses when it could not see; which outcome that implies is roadmap_publish's business." -data remote_branch_observation_note: String = "THE REFUSAL IS BUILT FROM A VALUE, NOT CAUGHT. git.Core.LsRemoteHeads declares success/exit_code/stderr as outputs, so a failed ref advertisement arrives as an ordinary record this function can match on. That is not true of every transport in the corpus - the REST dispatch raises on a non-2xx rather than returning one - and the difference is exactly why the remote-branch half of publication can be observed today and the pull-request half cannot." +data remote_branch_observation_note: String = "THE REFUSAL IS BUILT FROM A VALUE, NOT CAUGHT. git.Core.LsRemoteHeads declares exit_code/stderr/advertised as outputs (no exit_success projection — PR #7527 census), so a failed ref advertisement arrives as an ordinary record this function can match on. That is not true of every transport in the corpus - the REST dispatch raises on a non-2xx rather than returning one - and the difference is exactly why the remote-branch half of publication can be observed today and the pull-request half cannot." fn ls_remote_refusal_detail(remote: String, exit_code: Int, stderr: String) -> String { concat( @@ -45,12 +45,11 @@ data transport_shim_split_note: String = "THE READING IS PURE; ONLY THE CALL IS fn remote_branch_observation_of( remote: NonEmptyStr, - success: Bool, exit_code: Int, stderr: String, advertised: List, ) -> RemoteBranchObservation { - if !success { + if exit_code != 0 { RemoteBranchesUnreadable { detail: ls_remote_refusal_detail( remote: remote as String, @@ -91,7 +90,6 @@ fn observe_remote_branches(remote: NonEmptyStr) -> RemoteBranchObservation { let result = git.Core.LsRemoteHeads(remote: remote as String) remote_branch_observation_of( remote: remote, - success: result.success, exit_code: result.exit_code, stderr: result.stderr, advertised: result.advertised, diff --git a/dag/test/claim/direct_process_observation_witness_test.dag b/dag/test/claim/direct_process_observation_witness_test.dag new file mode 100644 index 00000000000..51b1e50eea0 --- /dev/null +++ b/dag/test/claim/direct_process_observation_witness_test.dag @@ -0,0 +1,144 @@ +module test.claim.direct_process_observation_witness_test + +import std.types { Bool, String, Int, Bytes } +import std.bytes { utf8_encode_bytes } +import gunbc.direct_process_observation { + DirectProcessObservation, + ProcessCompleted, + ProcessSpawnRefused, + ProcessWaitRefused, + ProcessExited, + ProcessSignaled, + ProcessTimedOut, + ProcessKilled, + direct_process_observation_from_shell, + direct_process_observation_completed_p, +} + +data witness_note: String = "Hermetic witnesses for gunbc.direct_process_observation: spawn/wait refusal, termination variants, and the absence of a precomputed success Bool." + +fn spawn_refusal_is_typed() -> Bool { + match direct_process_observation_from_shell( + spawn_ok: false, + wait_ok: true, + timed_out: false, + exit_code: 0, + signaled: false, + signal: 0, + exit_code_present: true, + stdout: utf8_encode_bytes(s: ""), + stderr: utf8_encode_bytes(s: ""), + spawn_detail: "spawn refused", + wait_detail: "", + ) { + ProcessSpawnRefused { detail } => (detail as String) == "spawn refused" + ProcessCompleted { termination: _, streams: _ } => false + ProcessWaitRefused { detail: _ } => false + } +} + +fn wait_refusal_is_typed() -> Bool { + match direct_process_observation_from_shell( + spawn_ok: true, + wait_ok: false, + timed_out: false, + exit_code: 0, + signaled: false, + signal: 0, + exit_code_present: true, + stdout: utf8_encode_bytes(s: ""), + stderr: utf8_encode_bytes(s: ""), + spawn_detail: "", + wait_detail: "wait refused", + ) { + ProcessWaitRefused { detail } => (detail as String) == "wait refused" + ProcessCompleted { termination: _, streams: _ } => false + ProcessSpawnRefused { detail: _ } => false + } +} + +fn exited_termination_carries_code_without_sentinel() -> Bool { + match direct_process_observation_from_shell( + spawn_ok: true, + wait_ok: true, + timed_out: false, + exit_code: 17, + signaled: false, + signal: 0, + exit_code_present: true, + stdout: utf8_encode_bytes(s: "ok"), + stderr: utf8_encode_bytes(s: ""), + spawn_detail: "", + wait_detail: "", + ) { + ProcessCompleted { termination, streams: _ } => + match termination { + ProcessExited { code } => code == 17 + ProcessSignaled { signal: _ } => false + ProcessTimedOut => false + ProcessKilled => false + } + ProcessSpawnRefused { detail: _ } => false + ProcessWaitRefused { detail: _ } => false + } +} + +fn absent_exit_code_becomes_killed_not_minus_one() -> Bool { + match direct_process_observation_from_shell( + spawn_ok: true, + wait_ok: true, + timed_out: false, + exit_code: 0, + signaled: false, + signal: 0, + exit_code_present: false, + stdout: utf8_encode_bytes(s: ""), + stderr: utf8_encode_bytes(s: ""), + spawn_detail: "", + wait_detail: "", + ) { + ProcessCompleted { termination, streams: _ } => + match termination { + ProcessKilled => true + ProcessExited { code: _ } => false + ProcessSignaled { signal: _ } => false + ProcessTimedOut => false + } + ProcessSpawnRefused { detail: _ } => false + ProcessWaitRefused { detail: _ } => false + } +} + +fn signaled_termination_is_not_exited_zero() -> Bool { + match direct_process_observation_from_shell( + spawn_ok: true, + wait_ok: true, + timed_out: false, + exit_code: 0, + signaled: true, + signal: 9, + exit_code_present: false, + stdout: utf8_encode_bytes(s: ""), + stderr: utf8_encode_bytes(s: ""), + spawn_detail: "", + wait_detail: "", + ) { + ProcessCompleted { termination, streams: _ } => + match termination { + ProcessSignaled { signal } => signal == 9 + ProcessExited { code: _ } => false + ProcessTimedOut => false + ProcessKilled => false + } + ProcessSpawnRefused { detail: _ } => false + ProcessWaitRefused { detail: _ } => false + } +} + +test fn direct_process_observation_keystone_holds() -> Bool { + spawn_refusal_is_typed() + && wait_refusal_is_typed() + && exited_termination_carries_code_without_sentinel() + && absent_exit_code_becomes_killed_not_minus_one() + && signaled_termination_is_not_exited_zero() +} diff --git a/dag/test/claim/git_ls_remote_advertisement_witness_test.dag b/dag/test/claim/git_ls_remote_advertisement_witness_test.dag new file mode 100644 index 00000000000..08c18b42638 --- /dev/null +++ b/dag/test/claim/git_ls_remote_advertisement_witness_test.dag @@ -0,0 +1,118 @@ +module test.claim.git_ls_remote_advertisement_witness_test + +import std.types { Bool, String, Int, List, NonEmptyStr, Bytes } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import extdeps.git { advertised_ref_branch_name } +import extdeps.git.ls_remote_advertisement { + GitAdvertisedRefDecodeOutcome, + GitAdvertisedRefsDecoded, + GitAdvertisedRefLineMalformed, + GitAdvertisedRefObjectIdRefused, + GitAdvertisedRefDuplicate, + GitAdvertisedRefEmptyBranchProjection, + GitAdvertisedRefLineAtObjectIdRefused, + GitAdvertisedRefLineAtMalformed, + GitAdvertisedRefLineAtEmptyBranchProjection, + GitAdvertisedRefLineDecoded, + git_decode_ls_remote_stdout, + git_decode_advertised_ref_line, +} +import extdeps.git.object_store { + GitObjectFormatSha1, + GitObjectIdTextNonHexadecimal, + GitObjectIdTextNonCanonicalUppercase, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data fixture_sha_a: String = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +data fixture_sha_b: String = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +fn tab() -> String { from_code_point(cp: 9) } + +fn line_of(sha: String, ref_name: String) -> String { + concat(sha, concat(tab(), ref_name)) +} + +fn branch_name_projects_from_full_ref() -> Bool { + advertised_ref_branch_name(ref_name: "refs/heads/main") == "main" + && advertised_ref_branch_name(ref_name: "refs/heads/dispatch/node-a7") == "dispatch/node-a7" +} + +fn malformed_line_refuses_at_index() -> Bool { + match git_decode_ls_remote_stdout( + stdout: concat( + line_of(sha: fixture_sha_a, ref_name: "refs/heads/main"), + concat("\n", "no-tab-line"), + ), + format: GitObjectFormatSha1, + ) { + GitAdvertisedRefLineMalformed { line_index, line } => + line_index == 1 && line == "no-tab-line" + GitAdvertisedRefsDecoded { refs: _ } => false + GitAdvertisedRefObjectIdRefused { line_index: _, line: _, cause: _ } => false + GitAdvertisedRefDuplicate { ref_name: _, first_index: _, second_index: _ } => false + GitAdvertisedRefEmptyBranchProjection { line_index: _, ref_name: _ } => false + } +} + +data fixture_sha_invalid: String = "gggggggggggggggggggggggggggggggggggggggg" + +fn invalid_object_id_refuses_not_brands() -> Bool { + match git_decode_advertised_ref_line( + line: line_of(sha: fixture_sha_invalid, ref_name: "refs/heads/main"), + line_index: 0, + format: GitObjectFormatSha1, + ) { + GitAdvertisedRefLineAtObjectIdRefused { cause, line_index: _, line: _ } => + match cause { + GitObjectIdTextNonHexadecimal => true + _ => false + } + GitAdvertisedRefLineDecoded { row: _ } => false + GitAdvertisedRefLineAtMalformed { line_index: _, line: _ } => false + GitAdvertisedRefLineAtEmptyBranchProjection { line_index: _, ref_name: _ } => false + } +} + +fn duplicate_ref_name_refuses_instead_of_first_match() -> Bool { + match git_decode_ls_remote_stdout( + stdout: concat( + concat( + line_of(sha: fixture_sha_a, ref_name: "refs/heads/main"), + "\n", + ), + line_of(sha: fixture_sha_b, ref_name: "refs/heads/main"), + ), + format: GitObjectFormatSha1, + ) { + GitAdvertisedRefDuplicate { ref_name, first_index, second_index } => + ref_name == "refs/heads/main" && first_index == 0 && second_index == 1 + GitAdvertisedRefsDecoded { refs: _ } => false + GitAdvertisedRefLineMalformed { line_index: _, line: _ } => false + GitAdvertisedRefObjectIdRefused { line_index: _, line: _, cause: _ } => false + GitAdvertisedRefEmptyBranchProjection { line_index: _, ref_name: _ } => false + } +} + +fn empty_heads_prefix_projection_refuses() -> Bool { + match git_decode_ls_remote_stdout( + stdout: line_of(sha: fixture_sha_a, ref_name: "refs/heads/"), + format: GitObjectFormatSha1, + ) { + GitAdvertisedRefEmptyBranchProjection { line_index, ref_name } => + line_index == 0 && ref_name == "refs/heads/" + GitAdvertisedRefsDecoded { refs: _ } => false + GitAdvertisedRefLineMalformed { line_index: _, line: _ } => false + GitAdvertisedRefObjectIdRefused { line_index: _, line: _, cause: _ } => false + GitAdvertisedRefDuplicate { ref_name: _, first_index: _, second_index: _ } => false + } +} + +test fn git_ls_remote_advertisement_keystone_holds() -> Bool { + branch_name_projects_from_full_ref() + && malformed_line_refuses_at_index() + && invalid_object_id_refuses_not_brands() + && duplicate_ref_name_refuses_instead_of_first_match() + && empty_heads_prefix_projection_refuses() +} diff --git a/dag/test/claim/repository_remote_head_real_execution_witness_test.dag b/dag/test/claim/repository_remote_head_real_execution_witness_test.dag new file mode 100644 index 00000000000..f72481c108a --- /dev/null +++ b/dag/test/claim/repository_remote_head_real_execution_witness_test.dag @@ -0,0 +1,93 @@ +module test.claim.repository_remote_head_real_execution_witness_test + +import std.types { Bool, String, NonEmptyStr, FilePath, Bytes } +import extdeps.shell +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import gunbc.repository_remote_head { + GitRepositoryBinding, + GitForgeIdentity, + GitCredentialUnauthenticated, + RepositoryRemoteHeadObservation, + RepositoryRemoteHeadsRead, + RepositoryRemoteHeadProcessRefused, + RepositoryRemoteHeadDecodeRefused, + observe_repository_remote_heads, + repository_remote_head_branch_sha, + repository_remote_head_invocation, +} +import v2.std.operation_argv { + ArgvMaterialized, + ArgvMaterializationRefused, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data real_execution_home_doc: String = "Wet execution witnesses for repository-bound remote-head observation against a temporary local bare repository. No network, no credentials, no live GitHub. Excluded from hermetic discovery and enrolled in bin_witness_wet_entries." + +fn witness_binding(remote_url: String, worktree: String) -> GitRepositoryBinding { + GitRepositoryBinding { + worktree: worktree as FilePath, + remote_url: remote_url as NonEmptyStr, + forge: GitForgeIdentity { remote_name: "origin" as NonEmptyStr }, + credential: GitCredentialUnauthenticated, + } +} + +fn run(script: String) -> Bool { + shell.Exec.Run(script: script as extdeps.shell.exec.TransportScript).success +} + +fn setup_bare_repo(bare: String, work: String) -> Bool { + run(script: concat("git init --bare ", bare)) + && run(script: concat("git init ", work)) + && run(script: concat("cd ", work, " && git config user.email witness@test && git config user.name witness")) + && run(script: concat("cd ", work, " && git remote add origin ", bare)) + && run(script: concat("cd ", work, " && echo file > f && git add f && git commit -m init && git push -u origin main")) +} + +test fn repository_remote_head_bare_repo_by_real_execution() -> Bool { + let root = shell.Mktemp.Dir() + let bare = concat(root.path as String, "/remote.git") + let work = concat(root.path as String, "/work") + let setup = setup_bare_repo(bare: bare, work: work) + let binding = witness_binding(remote_url: bare, worktree: work) + let inv = repository_remote_head_invocation(binding: binding) + let materialized = shell_materialize_operation_argv( + path: inv.at.path, + service: inv.at.service, + operation: inv.at.operation, + bindings: inv.bindings, + ) + let observed = observe_repository_remote_heads(binding: binding) + let pushed = run(script: concat("cd ", work, " && echo file2 >> f && git add f && git commit -m move && git push")) + let moved = observe_repository_remote_heads(binding: binding) + let unavailable = observe_repository_remote_heads( + binding: witness_binding(remote_url: concat(bare, "-missing"), worktree: work), + ) + let cleaned = shell.Remove.RecursiveForce(path: root.path) + let argv_ok = match materialized { + ArgvMaterializationRefused { at: _, cause: _ } => false + ArgvMaterialized { argv } => + count(argv) >= 3 && argv.first() == "git" + } + let observed_ok = match observed { + RepositoryRemoteHeadsRead { heads } => count(heads) == 1 + RepositoryRemoteHeadProcessRefused { observation: _ } => false + RepositoryRemoteHeadDecodeRefused { cause: _ } => false + } + let moved_ok = match moved { + RepositoryRemoteHeadsRead { heads } => count(heads) == 1 + RepositoryRemoteHeadProcessRefused { observation: _ } => false + RepositoryRemoteHeadDecodeRefused { cause: _ } => false + } + let unavailable_ok = match unavailable { + RepositoryRemoteHeadProcessRefused { observation: _ } => true + RepositoryRemoteHeadsRead { heads: _ } => false + RepositoryRemoteHeadDecodeRefused { cause: _ } => false + } + setup && pushed && argv_ok && observed_ok && moved_ok && unavailable_ok && cleaned.success +} + +test fn repository_remote_head_keystone_holds_by_real_execution() -> Bool { + repository_remote_head_bare_repo_by_real_execution() +} diff --git a/dag/test/claim/roadmap_publish_observe_witness_test.dag b/dag/test/claim/roadmap_publish_observe_witness_test.dag index d4b7d2f0558..ebb1e47ba0b 100644 --- a/dag/test/claim/roadmap_publish_observe_witness_test.dag +++ b/dag/test/claim/roadmap_publish_observe_witness_test.dag @@ -36,7 +36,6 @@ fn origin() -> NonEmptyStr { "origin" as NonEmptyStr } fn read_ok(advertised: List) -> RemoteBranchObservation { remote_branch_observation_of( remote: origin(), - success: true, exit_code: 0, stderr: "", advertised: advertised, @@ -75,7 +74,6 @@ data transport_refusal_note: String = "The claim that motivates the whole slice. test fn a_failed_ref_advertisement_refuses_rather_than_reading_empty() -> Bool { let o = remote_branch_observation_of( remote: origin(), - success: false, exit_code: 128, stderr: "fatal: repository not found", advertised: [], diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index 4533c72facb..05c864cb787 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -5711,9 +5711,45 @@ fn build_service_param_env( } struct ShellResult { - exit_code: i32, - stdout: String, - stderr: String, + exit_code: Option, + signaled: bool, + signal: i32, + stdout_raw: Vec, + stderr_raw: Vec, +} + +fn bytes_to_value(bytes: &[u8]) -> Value { + list_value( + bytes + .iter() + .map(|b| Value::Int(i64::from(*b))) + .collect::>(), + ) +} + +fn shell_stdout_string_lossy(bytes: &[u8]) -> String { + // String stdout is a lossy CLI projection; stdout_bytes retains faithful bytes. + String::from_utf8_lossy(bytes).trim_end().to_string() +} + +fn shell_exit_succeeded(result: &ShellResult) -> bool { + !result.signaled && result.exit_code == Some(0) +} + +fn shell_declared_exit_code(result: &ShellResult) -> InterpResult { + match result.exit_code { + Some(code) => Ok(i64::from(code)), + None => Err(InterpError::TypeError { + msg: if result.signaled { + format!( + "declared shell exit_code is absent: process terminated by signal {}", + result.signal + ) + } else { + "declared shell exit_code is absent".to_string() + }, + }), + } } fn push_shell_argv_tokens(argv: &mut Vec, val: Value) -> InterpResult<()> { @@ -6849,6 +6885,8 @@ fn dispatch_shell( intent: &str, expected: ExpectedOutcome, ) -> InterpResult { + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; // `expected` is the caller's DECLARED expectation, threaded from the sibling edge // on the call node (EFFECT_EXPECTATION_ARG). Absent at the call site it is // ExpectSuccess, which keeps every undeclared site behaviour-identical to the @@ -6953,16 +6991,25 @@ fn dispatch_shell( output }; - let exit_code = output.status.code().unwrap_or(-1); + let exit_code = output.status.code(); + #[cfg(unix)] + let signaled = output + .status + .signal() + .is_some_and(|_| !output.status.success() && exit_code.is_none()); + #[cfg(not(unix))] + let signaled = false; + #[cfg(unix)] + let signal = output.status.signal().unwrap_or(0) as i32; + #[cfg(not(unix))] + let signal = 0; Ok(ShellResult { exit_code, - stdout: String::from_utf8_lossy(&output.stdout) - .trim_end() - .to_string(), - stderr: String::from_utf8_lossy(&output.stderr) - .trim_end() - .to_string(), + signaled, + signal, + stdout_raw: output.stdout, + stderr_raw: output.stderr, }) } @@ -6974,7 +7021,7 @@ fn map_shell_outputs( let return_type = match op_node.inferred.as_deref() { Some(crate::v1_std_core::InferredNode::Resolved { node }) => node.clone(), _ => { - return Ok(Value::Str(result.stdout.clone())); + return Ok(Value::Str(shell_stdout_string_lossy(&result.stdout_raw))); } }; @@ -6988,27 +7035,39 @@ fn map_shell_outputs( let field_name = authored_name_at(ctx.si(), child.clone()); let from_key = extract_from_key(child, ctx); let is_optional_field = child.return_cardinality == Cardinality::CardOptional; + let process_failed = result.signaled || result.exit_code != Some(0); + let stdout_str = shell_stdout_string_lossy(&result.stdout_raw); + let stderr_str = shell_stdout_string_lossy(&result.stderr_raw); let value = match from_key.as_deref() { - Some("stdout") if is_optional_field && result.exit_code != 0 => Value::Null, - Some("stderr") if is_optional_field && result.exit_code != 0 => Value::Null, - Some("stdout") => Value::Str(result.stdout.clone()), - Some("stderr") => Value::Str(result.stderr.clone()), - Some("exit_success") => Value::Bool(result.exit_code == 0), - Some("exit_code") => Value::Int(result.exit_code as i64), + Some("stdout") if is_optional_field && process_failed => Value::Null, + Some("stderr") if is_optional_field && process_failed => Value::Null, + Some("stdout") => Value::Str(stdout_str.clone()), + Some("stderr") => Value::Str(stderr_str.clone()), + Some("stdout_bytes") => bytes_to_value(&result.stdout_raw), + Some("stderr_bytes") => bytes_to_value(&result.stderr_raw), + Some("exit_success") => Value::Bool(shell_exit_succeeded(result)), + Some("exit_code") => Value::Int(shell_declared_exit_code(result)?), + Some("exit_code_present") => Value::Bool(result.exit_code.is_some()), + Some("signaled") => Value::Bool(result.signaled), + Some("signal") => Value::Int(result.signal as i64), Some("stdout_lines") => { - let lines: Vec = result - .stdout + let lines: Vec = stdout_str .lines() .map(|l| Value::Str(l.to_string())) .collect(); list_value((lines)) } _ => match field_name.as_str() { - "success" => Value::Bool(result.exit_code == 0), - "exit_code" => Value::Int(result.exit_code as i64), - "stdout" => Value::Str(result.stdout.clone()), - "stderr" => Value::Str(result.stderr.clone()), - "exists" => Value::Bool(result.exit_code == 0), + "success" => Value::Bool(shell_exit_succeeded(result)), + "exit_code" => Value::Int(shell_declared_exit_code(result)?), + "exit_code_present" => Value::Bool(result.exit_code.is_some()), + "signaled" => Value::Bool(result.signaled), + "signal" => Value::Int(result.signal as i64), + "stdout" => Value::Str(stdout_str.clone()), + "stderr" => Value::Str(stderr_str.clone()), + "stdout_bytes" => bytes_to_value(&result.stdout_raw), + "stderr_bytes" => bytes_to_value(&result.stderr_raw), + "exists" => Value::Bool(shell_exit_succeeded(result)), _ => Value::Null, }, };