Public release release-09ca780bd67a
Source commit: 09ca780bd67a00467e78139cf38466b8201b66ca Public tree identity: sha256:2f744c260b5fc2cf074ad9129f97f87f03714902e5b9fe174128afdc342ec2d0
This commit is contained in:
commit
eb1077f380
221 changed files with 81144 additions and 0 deletions
21
crates/clusterflux-dap/Cargo.toml
Normal file
21
crates/clusterflux-dap/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "clusterflux-dap"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "clusterflux-debug-dap"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
clusterflux-core = { path = "../clusterflux-core" }
|
||||
clusterflux-control = { path = "../clusterflux-control" }
|
||||
clusterflux-node = { path = "../clusterflux-node" }
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
1263
crates/clusterflux-dap/src/adapter.rs
Normal file
1263
crates/clusterflux-dap/src/adapter.rs
Normal file
File diff suppressed because it is too large
Load diff
375
crates/clusterflux-dap/src/breakpoints.rs
Normal file
375
crates/clusterflux-dap/src/breakpoints.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use std::fs;
|
||||
|
||||
use clusterflux_core::{
|
||||
discover_source_debug_probes, BundleDebugProbe, DebugRuntimeState, TaskInstanceId,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
|
||||
use crate::virtual_model::{AdapterState, VirtualThread};
|
||||
|
||||
pub(crate) fn request_thread<'a>(request: &Value, state: &'a AdapterState) -> &'a VirtualThread {
|
||||
let thread_id = request_thread_id(request).unwrap_or(MAIN_THREAD);
|
||||
state
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.unwrap_or_else(|| &state.threads[&MAIN_THREAD])
|
||||
}
|
||||
|
||||
pub(crate) fn request_thread_id(request: &Value) -> Option<i64> {
|
||||
request
|
||||
.get("arguments")
|
||||
.and_then(|value| value.get("threadId"))
|
||||
.and_then(Value::as_i64)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ResolvedBreakpoint {
|
||||
pub(crate) id: usize,
|
||||
pub(crate) line: i64,
|
||||
pub(crate) verified: bool,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl ResolvedBreakpoint {
|
||||
pub(crate) fn to_dap(&self) -> Value {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"verified": self.verified,
|
||||
"line": self.line,
|
||||
"message": self.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_bundle_debug_probes(project: &str, source_path: &str) -> Vec<BundleDebugProbe> {
|
||||
let source = fs::read_to_string(crate::source::resolve_source_path(project, source_path));
|
||||
source
|
||||
.map(|source| discover_source_debug_probes(source_path, &source))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_breakpoints(
|
||||
state: &mut AdapterState,
|
||||
requested_lines: Vec<i64>,
|
||||
) -> Vec<ResolvedBreakpoint> {
|
||||
let resolved = requested_lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
let probe = debug_probe_for_line(state, line);
|
||||
let verified = probe.is_some()
|
||||
|| (state.debug_probes.is_empty()
|
||||
&& source_function_name_at_line(state, line).is_some());
|
||||
let message = match probe {
|
||||
Some(probe) => format!(
|
||||
"Mapped to Clusterflux debug probe {} for task {}",
|
||||
probe.id, probe.task
|
||||
),
|
||||
None if verified => "Mapped to Clusterflux virtual source location".to_owned(),
|
||||
None => "No Clusterflux debug probe metadata covers this source line".to_owned(),
|
||||
};
|
||||
ResolvedBreakpoint {
|
||||
id: index + 1,
|
||||
line,
|
||||
verified,
|
||||
message,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
state.breakpoints = resolved
|
||||
.iter()
|
||||
.filter(|breakpoint| breakpoint.verified)
|
||||
.map(|breakpoint| breakpoint.line)
|
||||
.collect();
|
||||
resolved
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_breakpoints_for_source(
|
||||
state: &mut AdapterState,
|
||||
requested_source_path: Option<&str>,
|
||||
requested_lines: Vec<i64>,
|
||||
) -> Vec<ResolvedBreakpoint> {
|
||||
if requested_source_path.is_none_or(|requested_source_path| {
|
||||
crate::source::source_paths_match(&state.project, &state.source_path, requested_source_path)
|
||||
}) {
|
||||
return resolve_breakpoints(state, requested_lines);
|
||||
}
|
||||
|
||||
requested_lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| ResolvedBreakpoint {
|
||||
id: index + 1,
|
||||
line,
|
||||
verified: false,
|
||||
message: format!(
|
||||
"This debug session is configured for `{}`; breakpoints from another source file are not applied",
|
||||
state.source_path
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn restart_requires_whole_process(request: &Value) -> bool {
|
||||
let Some(arguments) = request.get("arguments") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
arguments
|
||||
.get("requiresWholeProcessRestart")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| [
|
||||
"compatibility",
|
||||
"sourceCompatibility",
|
||||
"sourceEditCompatibility",
|
||||
"taskCompatibility",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|field| arguments.get(field).and_then(Value::as_str))
|
||||
.any(is_incompatible_restart)
|
||||
|| arguments
|
||||
.get("sourceEdit")
|
||||
.and_then(|value| value.get("compatibility"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_incompatible_restart)
|
||||
}
|
||||
|
||||
fn is_incompatible_restart(value: &str) -> bool {
|
||||
value.eq_ignore_ascii_case("incompatible")
|
||||
|| value.eq_ignore_ascii_case("whole-process")
|
||||
|| value.eq_ignore_ascii_case("whole_process")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct FreezeFailure {
|
||||
pub(crate) thread_id: i64,
|
||||
pub(crate) task: TaskInstanceId,
|
||||
}
|
||||
|
||||
impl FreezeFailure {
|
||||
pub(crate) fn message(&self) -> String {
|
||||
format!(
|
||||
"debug all-stop failed: participant `{}` on thread {} could not freeze",
|
||||
self.task, self.thread_id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn simulated_freeze_failure_thread(request: &Value) -> Option<i64> {
|
||||
request
|
||||
.get("arguments")
|
||||
.and_then(|arguments| arguments.get("simulateFreezeFailure"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
.then_some(PACKAGE_THREAD)
|
||||
}
|
||||
|
||||
pub(crate) fn freeze_all(
|
||||
state: &mut AdapterState,
|
||||
stopped_thread: i64,
|
||||
forced_failure_thread: Option<i64>,
|
||||
) -> Result<(), FreezeFailure> {
|
||||
let line = state
|
||||
.breakpoints
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|line| thread_for_source_line(state, *line) == stopped_thread)
|
||||
.or_else(|| state.breakpoints.first().copied())
|
||||
.unwrap_or_else(|| state.threads[&stopped_thread].line);
|
||||
freeze_all_at_line(state, stopped_thread, line, forced_failure_thread)
|
||||
}
|
||||
|
||||
pub(crate) fn freeze_all_at_line(
|
||||
state: &mut AdapterState,
|
||||
stopped_thread: i64,
|
||||
line: i64,
|
||||
forced_failure_thread: Option<i64>,
|
||||
) -> Result<(), FreezeFailure> {
|
||||
if let Some(failure) = state.threads.values().find(|thread| {
|
||||
thread.state == DebugRuntimeState::Running
|
||||
&& (!thread.freeze_supported || forced_failure_thread == Some(thread.id))
|
||||
}) {
|
||||
return Err(FreezeFailure {
|
||||
thread_id: failure.id,
|
||||
task: failure.task.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
state.epoch += 1;
|
||||
for thread in state.threads.values_mut() {
|
||||
if thread.state == DebugRuntimeState::Running {
|
||||
thread.state = DebugRuntimeState::Frozen;
|
||||
}
|
||||
}
|
||||
if let Some(thread) = state.threads.get_mut(&stopped_thread) {
|
||||
thread.line = line;
|
||||
thread
|
||||
.recent_output
|
||||
.push(format!("debug epoch {} all-stop", state.epoch));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn stopped_thread_for_breakpoint(state: &AdapterState) -> i64 {
|
||||
if let Some(stopped_task) = state.stopped_task.as_ref() {
|
||||
if let Some(thread) = state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| &thread.task == stopped_task)
|
||||
{
|
||||
return thread.id;
|
||||
}
|
||||
}
|
||||
state
|
||||
.breakpoints
|
||||
.first()
|
||||
.map(|line| thread_for_source_line(state, *line))
|
||||
.unwrap_or(MAIN_THREAD)
|
||||
}
|
||||
|
||||
pub(crate) fn position_confirmed_breakpoint_stop(state: &mut AdapterState, stopped_thread: i64) {
|
||||
let line = state
|
||||
.stopped_probe_symbol
|
||||
.as_deref()
|
||||
.and_then(|symbol| symbol.strip_prefix("clusterflux.probe."))
|
||||
.and_then(|function| {
|
||||
state
|
||||
.debug_probes
|
||||
.iter()
|
||||
.find(|probe| probe.function == function)
|
||||
.and_then(|probe| {
|
||||
state.breakpoints.iter().copied().find(|line| {
|
||||
*line >= i64::from(probe.line_start) && *line <= i64::from(probe.line_end)
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
state
|
||||
.breakpoints
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|line| thread_for_source_line(state, *line) == stopped_thread)
|
||||
})
|
||||
.or_else(|| state.breakpoints.first().copied());
|
||||
if let (Some(line), Some(thread)) = (line, state.threads.get_mut(&stopped_thread)) {
|
||||
thread.line = line;
|
||||
thread.recent_output.push(format!(
|
||||
"debug epoch {} confirmed frozen by all participants",
|
||||
state.epoch
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next_breakpoint_after(state: &AdapterState, thread_id: i64) -> Option<(i64, i64)> {
|
||||
let current_line = state.threads.get(&thread_id)?.line;
|
||||
state
|
||||
.breakpoints
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|line| *line > current_line)
|
||||
.min()
|
||||
.map(|line| (thread_for_source_line(state, line), line))
|
||||
}
|
||||
|
||||
fn thread_for_source_line(state: &AdapterState, line: i64) -> i64 {
|
||||
if let Some(probe) = debug_probe_for_line(state, line) {
|
||||
if state.runtime_backend == crate::virtual_model::RuntimeBackend::Simulated {
|
||||
let function = probe.function.to_ascii_lowercase();
|
||||
if function.contains("linux") {
|
||||
return LINUX_THREAD;
|
||||
}
|
||||
if function.contains("windows") {
|
||||
return WINDOWS_THREAD;
|
||||
}
|
||||
if function.contains("package") {
|
||||
return PACKAGE_THREAD;
|
||||
}
|
||||
return MAIN_THREAD;
|
||||
}
|
||||
return state
|
||||
.stopped_task
|
||||
.as_ref()
|
||||
.and_then(|task| {
|
||||
state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| &thread.task == task)
|
||||
.map(|thread| thread.id)
|
||||
})
|
||||
.unwrap_or(MAIN_THREAD);
|
||||
}
|
||||
|
||||
if let Some(function_name) = source_function_name_at_line(state, line) {
|
||||
let lower = function_name.to_ascii_lowercase();
|
||||
if lower.contains("linux") {
|
||||
return LINUX_THREAD;
|
||||
}
|
||||
if lower.contains("windows") {
|
||||
return WINDOWS_THREAD;
|
||||
}
|
||||
if lower.contains("package") {
|
||||
return PACKAGE_THREAD;
|
||||
}
|
||||
return MAIN_THREAD;
|
||||
}
|
||||
|
||||
state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| thread.line == line)
|
||||
.map(|thread| thread.id)
|
||||
.unwrap_or(MAIN_THREAD)
|
||||
}
|
||||
|
||||
fn debug_probe_for_line(state: &AdapterState, line: i64) -> Option<&BundleDebugProbe> {
|
||||
let line = u32::try_from(line).ok()?;
|
||||
state
|
||||
.debug_probes
|
||||
.iter()
|
||||
.find(|probe| probe.line_start <= line && line <= probe.line_end)
|
||||
}
|
||||
|
||||
pub(crate) fn source_function_name_at_line(state: &AdapterState, line: i64) -> Option<String> {
|
||||
let source = fs::read_to_string(crate::source::resolve_source_path(
|
||||
&state.project,
|
||||
&state.source_path,
|
||||
))
|
||||
.ok()?;
|
||||
let lines = source.lines().collect::<Vec<_>>();
|
||||
let mut index = usize::try_from(line).ok()?.saturating_sub(1);
|
||||
if index >= lines.len() {
|
||||
index = lines.len().saturating_sub(1);
|
||||
}
|
||||
lines[..=index]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|line| parse_rust_function_name(line))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_rust_function_name(line: &str) -> Option<String> {
|
||||
let start = line.find("fn ")? + 3;
|
||||
let rest = &line[start..];
|
||||
let name = rest
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
|
||||
.collect::<String>();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
pub(crate) fn step_thread(state: &mut AdapterState, thread_id: i64, description: &str) {
|
||||
state.epoch += 1;
|
||||
let resolved_thread = if state.threads.contains_key(&thread_id) {
|
||||
thread_id
|
||||
} else {
|
||||
LINUX_THREAD
|
||||
};
|
||||
if let Some(thread) = state.threads.get_mut(&resolved_thread) {
|
||||
thread.state = DebugRuntimeState::Frozen;
|
||||
thread.line += 1;
|
||||
thread
|
||||
.recent_output
|
||||
.push(format!("debug epoch {} {description}", state.epoch));
|
||||
}
|
||||
}
|
||||
129
crates/clusterflux-dap/src/dap_protocol.rs
Normal file
129
crates/clusterflux-dap/src/dap_protocol.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DapWriter {
|
||||
seq: i64,
|
||||
}
|
||||
|
||||
impl DapWriter {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { seq: 1 }
|
||||
}
|
||||
|
||||
pub(crate) fn response(
|
||||
&mut self,
|
||||
request: &Value,
|
||||
success: bool,
|
||||
body: Value,
|
||||
) -> io::Result<()> {
|
||||
let command = request
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("<unknown>");
|
||||
let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
|
||||
let seq = self.next_seq();
|
||||
self.write(json!({
|
||||
"seq": seq,
|
||||
"type": "response",
|
||||
"request_seq": request_seq,
|
||||
"success": success,
|
||||
"command": command,
|
||||
"body": body,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn error_response(
|
||||
&mut self,
|
||||
request: &Value,
|
||||
message: impl Into<String>,
|
||||
) -> io::Result<()> {
|
||||
let command = request
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("<unknown>");
|
||||
let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
|
||||
let seq = self.next_seq();
|
||||
self.write(json!({
|
||||
"seq": seq,
|
||||
"type": "response",
|
||||
"request_seq": request_seq,
|
||||
"success": false,
|
||||
"command": command,
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn event(&mut self, event: &str, body: Value) -> io::Result<()> {
|
||||
let seq = self.next_seq();
|
||||
self.write(json!({
|
||||
"seq": seq,
|
||||
"type": "event",
|
||||
"event": event,
|
||||
"body": body,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn output(&mut self, category: &str, output: impl Into<String>) -> io::Result<()> {
|
||||
self.event(
|
||||
"output",
|
||||
json!({
|
||||
"category": category,
|
||||
"output": output.into(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn next_seq(&mut self) -> i64 {
|
||||
let seq = self.seq;
|
||||
self.seq += 1;
|
||||
seq
|
||||
}
|
||||
|
||||
fn write(&mut self, message: Value) -> io::Result<()> {
|
||||
let payload = serde_json::to_vec(&message)?;
|
||||
let mut out = io::stdout().lock();
|
||||
write!(out, "Content-Length: {}\r\n\r\n", payload.len())?;
|
||||
out.write_all(&payload)?;
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_capabilities() -> Value {
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsTerminateRequest": true,
|
||||
"supportsRestartRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"supportsEvaluateForHovers": true,
|
||||
"supportsStepBack": false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_message<R: BufRead>(reader: &mut R) -> Result<Option<Value>> {
|
||||
let mut content_length = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes = reader.read_line(&mut line)?;
|
||||
if bytes == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||
content_length = Some(value.trim().parse::<usize>()?);
|
||||
}
|
||||
}
|
||||
|
||||
let length = content_length.ok_or_else(|| anyhow!("DAP message missing Content-Length"))?;
|
||||
let mut payload = vec![0; length];
|
||||
reader.read_exact(&mut payload)?;
|
||||
Ok(Some(serde_json::from_slice(&payload)?))
|
||||
}
|
||||
68
crates/clusterflux-dap/src/demo_backend.rs
Normal file
68
crates/clusterflux-dap/src/demo_backend.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use clusterflux_core::{DebugRuntimeState, TaskInstanceId};
|
||||
|
||||
use crate::virtual_model::{AdapterState, VirtualThread};
|
||||
|
||||
pub(crate) const MAIN_THREAD: i64 = 1;
|
||||
pub(crate) const LINUX_THREAD: i64 = 2;
|
||||
pub(crate) const WINDOWS_THREAD: i64 = 3;
|
||||
pub(crate) const PACKAGE_THREAD: i64 = 4;
|
||||
|
||||
pub(crate) fn is_explicit_demo_backend(value: &str) -> bool {
|
||||
value == "simulated" || value == "demo"
|
||||
}
|
||||
|
||||
pub(crate) fn launch_threads(entry: &str) -> BTreeMap<i64, VirtualThread> {
|
||||
[
|
||||
thread(MAIN_THREAD, "main", &format!("{entry} virtual process"), 12),
|
||||
thread(LINUX_THREAD, "compile-linux", "compile linux", 42),
|
||||
thread(WINDOWS_THREAD, "compile-windows", "compile windows", 52),
|
||||
thread(PACKAGE_THREAD, "package-release", "package artifacts", 64),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|thread| (thread.id, thread))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn start_simulated_backend(state: &mut AdapterState) {
|
||||
state.command_status = "running under simulated debug adapter state".to_owned();
|
||||
}
|
||||
|
||||
fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread {
|
||||
VirtualThread {
|
||||
id,
|
||||
frame_id: 1000 + id,
|
||||
locals_ref: 5000 + id,
|
||||
wasm_locals_ref: 9000 + id,
|
||||
args_ref: 2000 + id,
|
||||
runtime_ref: 3000 + id,
|
||||
output_ref: 4000 + id,
|
||||
target_ref: 6000 + id,
|
||||
vfs_ref: 7000 + id,
|
||||
command_ref: 8000 + id,
|
||||
task: TaskInstanceId::from(task),
|
||||
attempt_id: format!("demo-attempt-{id}"),
|
||||
task_definition: clusterflux_core::TaskDefinitionId::from(task),
|
||||
name: name.to_owned(),
|
||||
line,
|
||||
state: DebugRuntimeState::Running,
|
||||
freeze_supported: true,
|
||||
recent_output: vec![format!("{name}: waiting")],
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: 0,
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: String::new(),
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
runtime_stack_frames: Vec::new(),
|
||||
wasm_local_values: Vec::new(),
|
||||
runtime_task_args: Vec::new(),
|
||||
runtime_handles: Vec::new(),
|
||||
runtime_command_status: None,
|
||||
runtime_node: Some("demo-node".to_owned()),
|
||||
runtime_environment: Some("demo".to_owned()),
|
||||
runtime_vfs_checkpoint: Some("demo-vfs".to_owned()),
|
||||
restart_compatible: Some(true),
|
||||
}
|
||||
}
|
||||
43
crates/clusterflux-dap/src/main.rs
Normal file
43
crates/clusterflux-dap/src/main.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use anyhow::Result;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
|
||||
mod adapter;
|
||||
mod breakpoints;
|
||||
mod dap_protocol;
|
||||
mod demo_backend;
|
||||
mod runtime_client;
|
||||
mod source;
|
||||
mod variables;
|
||||
mod view_state;
|
||||
mod virtual_model;
|
||||
|
||||
#[cfg(test)]
|
||||
use adapter::runtime_backend_from_launch_arg;
|
||||
#[cfg(test)]
|
||||
use breakpoints::{
|
||||
freeze_all, resolve_breakpoints, resolve_breakpoints_for_source,
|
||||
restart_requires_whole_process, stopped_thread_for_breakpoint,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use dap_protocol::{initialize_capabilities, read_message};
|
||||
#[cfg(test)]
|
||||
use runtime_client::{client_user_request, parse_task_restart_response};
|
||||
#[cfg(test)]
|
||||
use variables::variables_response;
|
||||
#[cfg(test)]
|
||||
use virtual_model::{AdapterState, RuntimeLaunchRecord};
|
||||
|
||||
#[cfg(test)]
|
||||
use clusterflux_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId};
|
||||
#[cfg(test)]
|
||||
use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
|
||||
#[cfg(test)]
|
||||
use virtual_model::{process_id, RuntimeBackend};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
adapter::run_adapter()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
1459
crates/clusterflux-dap/src/runtime_client.rs
Normal file
1459
crates/clusterflux-dap/src/runtime_client.rs
Normal file
File diff suppressed because it is too large
Load diff
243
crates/clusterflux-dap/src/runtime_client/debug_protocol.rs
Normal file
243
crates/clusterflux-dap/src/runtime_client/debug_protocol.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use clusterflux_core::TaskInstanceId;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
use super::{
|
||||
client_user_request, coordinator_request, DebugEpochRecord, DebugEpochStatusRecord,
|
||||
TaskRestartRecord,
|
||||
};
|
||||
|
||||
pub(super) fn coordinator_debug_epoch_request(
|
||||
state: &AdapterState,
|
||||
payload: Value,
|
||||
) -> Result<Value> {
|
||||
let coordinator =
|
||||
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
|
||||
coordinator_request(&coordinator, payload)
|
||||
}
|
||||
|
||||
pub(super) fn parse_debug_epoch_response(response: Value) -> Result<DebugEpochRecord> {
|
||||
let epoch = response
|
||||
.get("epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch response did not include an epoch"))?;
|
||||
let command = response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch response did not include a command"))?
|
||||
.to_owned();
|
||||
let affected_tasks = response
|
||||
.get("affected_tasks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
Ok(DebugEpochRecord {
|
||||
epoch,
|
||||
command,
|
||||
affected_tasks,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_debug_epoch_state(
|
||||
state: &AdapterState,
|
||||
epoch: u64,
|
||||
frozen: bool,
|
||||
) -> Result<DebugEpochStatusRecord> {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = match coordinator_debug_epoch_request(
|
||||
state,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "inspect_debug_epoch",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"epoch": epoch,
|
||||
}),
|
||||
),
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) if !frozen && debug_epoch_was_released(&error, state, epoch) => {
|
||||
return Ok(DebugEpochStatusRecord {
|
||||
epoch,
|
||||
command: "resume".to_owned(),
|
||||
expected_tasks: 0,
|
||||
acknowledgements: Vec::new(),
|
||||
fully_frozen: false,
|
||||
partially_frozen: false,
|
||||
fully_resumed: true,
|
||||
failed: false,
|
||||
failure_messages: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let status = parse_debug_epoch_status(response)?;
|
||||
if frozen && (status.fully_frozen || status.partially_frozen) {
|
||||
return Ok(status);
|
||||
}
|
||||
if status.failed {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {epoch} participant failed: {}",
|
||||
status.failure_messages.join("; ")
|
||||
));
|
||||
}
|
||||
if !frozen && status.fully_resumed {
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {epoch} did not receive {}/{} signed participant acknowledgements for {} within 60 seconds",
|
||||
status.acknowledgements.len(),
|
||||
status.expected_tasks,
|
||||
if frozen { "frozen state" } else { "resumed state" }
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_epoch_was_released(error: &anyhow::Error, state: &AdapterState, epoch: u64) -> bool {
|
||||
format!("{error:#}").contains(&format!(
|
||||
"debug epoch {epoch} is not active for {}",
|
||||
state.process
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_debug_epoch_status(response: Value) -> Result<DebugEpochStatusRecord> {
|
||||
let epoch = response
|
||||
.get("epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch status omitted epoch"))?;
|
||||
let command = response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch status omitted command"))?
|
||||
.to_owned();
|
||||
let expected_tasks = response
|
||||
.get("expected_tasks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
let acknowledgements = response
|
||||
.get("acknowledgements")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let failure_messages = response
|
||||
.get("failure_messages")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
Ok(DebugEpochStatusRecord {
|
||||
epoch,
|
||||
command,
|
||||
expected_tasks,
|
||||
acknowledgements,
|
||||
fully_frozen: response
|
||||
.get("fully_frozen")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
partially_frozen: response
|
||||
.get("partially_frozen")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
fully_resumed: response
|
||||
.get("fully_resumed")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
failed: response
|
||||
.get("failed")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
failure_messages,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_task_restart_response(response: Value) -> Result<TaskRestartRecord> {
|
||||
Ok(TaskRestartRecord {
|
||||
accepted: response
|
||||
.get("accepted")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| anyhow!("coordinator task restart response did not include accepted"))?,
|
||||
restarted_task_instance: response
|
||||
.get("restarted_task_instance")
|
||||
.and_then(Value::as_str)
|
||||
.map(TaskInstanceId::new),
|
||||
restarted_attempt_id: response
|
||||
.get("restarted_attempt_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
clean_boundary_available: response
|
||||
.get("clean_boundary_available")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("coordinator task restart response did not include clean_boundary_available")
|
||||
})?,
|
||||
requires_whole_process_restart: response
|
||||
.get("requires_whole_process_restart")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"coordinator task restart response did not include requires_whole_process_restart"
|
||||
)
|
||||
})?,
|
||||
active_task: response
|
||||
.get("active_task")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| anyhow!("coordinator task restart response did not include active_task"))?,
|
||||
completed_event_observed: response
|
||||
.get("completed_event_observed")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("coordinator task restart response did not include completed_event_observed")
|
||||
})?,
|
||||
message: response
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("coordinator task restart response did not include message"))?
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::debug_epoch_was_released;
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
#[test]
|
||||
fn accepts_epoch_release_after_an_accepted_resume() {
|
||||
let state = AdapterState {
|
||||
process: "vp-completed".into(),
|
||||
..AdapterState::default()
|
||||
};
|
||||
|
||||
assert!(debug_epoch_was_released(
|
||||
&anyhow!("coordinator protocol error: debug epoch 3 is not active for vp-completed"),
|
||||
&state,
|
||||
3,
|
||||
));
|
||||
assert!(!debug_epoch_was_released(
|
||||
&anyhow!("coordinator protocol error: debug epoch 4 is not active for vp-completed"),
|
||||
&state,
|
||||
3,
|
||||
));
|
||||
assert!(!debug_epoch_was_released(
|
||||
&anyhow!("coordinator protocol error: debug epoch 3 is not active for vp-other"),
|
||||
&state,
|
||||
3,
|
||||
));
|
||||
}
|
||||
}
|
||||
63
crates/clusterflux-dap/src/runtime_client/local_tools.rs
Normal file
63
crates/clusterflux-dap/src/runtime_client/local_tools.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
|
||||
pub(super) fn child_stderr_suffix(child: &mut Child) -> String {
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut stream) = child.stderr.take() {
|
||||
let _ = stream.read_to_string(&mut stderr);
|
||||
}
|
||||
let stderr = stderr.trim();
|
||||
if stderr.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; worker stderr: {stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn local_tool_command(
|
||||
env_var: &str,
|
||||
binary: &str,
|
||||
package: &str,
|
||||
repo: &Path,
|
||||
) -> Command {
|
||||
if let Some(path) = std::env::var_os(env_var) {
|
||||
return Command::new(path);
|
||||
}
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
if !workspace_development_executable(current_exe.as_deref(), repo) {
|
||||
if let Some(path) = sibling_tool_path(current_exe.as_deref(), binary) {
|
||||
return Command::new(path);
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace-built adapter must ask Cargo for the matching service binary:
|
||||
// a sibling executable can be older than the adapter even though both happen
|
||||
// to live in target/debug. Installed releases take the sibling path above.
|
||||
let mut command = Command::new("cargo");
|
||||
command.args(["run", "-q", "-p", package, "--bin", binary, "--"]);
|
||||
command
|
||||
}
|
||||
|
||||
fn sibling_tool_path(current_exe: Option<&Path>, binary: &str) -> Option<PathBuf> {
|
||||
let mut sibling = current_exe?.to_path_buf();
|
||||
sibling.set_file_name(format!("{binary}{}", std::env::consts::EXE_SUFFIX));
|
||||
sibling.is_file().then_some(sibling)
|
||||
}
|
||||
|
||||
fn workspace_development_executable(current_exe: Option<&Path>, repo: &Path) -> bool {
|
||||
let Some(current_exe) = current_exe else {
|
||||
return false;
|
||||
};
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
repo.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| repo.join("target"));
|
||||
current_exe.starts_with(target)
|
||||
}
|
||||
53
crates/clusterflux-dap/src/runtime_client/transport.rs
Normal file
53
crates/clusterflux-dap/src/runtime_client/transport.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use clusterflux_control::ControlSession;
|
||||
use clusterflux_core::coordinator_wire_request;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
pub(crate) fn client_user_request(state: &AdapterState, mut request: Value) -> Value {
|
||||
let Some(session_secret) = state.client_session_secret.as_deref() else {
|
||||
return request;
|
||||
};
|
||||
if let Value::Object(fields) = &mut request {
|
||||
fields.remove("tenant");
|
||||
fields.remove("project");
|
||||
fields.remove("actor_user");
|
||||
}
|
||||
json!({
|
||||
"type": "authenticated",
|
||||
"session_secret": session_secret,
|
||||
"request": request,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct CoordinatorSession {
|
||||
session: ControlSession,
|
||||
}
|
||||
|
||||
impl CoordinatorSession {
|
||||
pub(super) fn connect(addr: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
session: ControlSession::connect(addr)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn request(&mut self, request: Value) -> Result<Value> {
|
||||
let wire_request = coordinator_wire_request("dap-1", request);
|
||||
let response = self.session.request(&wire_request)?;
|
||||
if response.get("type").and_then(Value::as_str) == Some("error") {
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
response
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("coordinator returned an error")
|
||||
));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn coordinator_request(addr: &str, request: Value) -> Result<Value> {
|
||||
CoordinatorSession::connect(addr)?.request(request)
|
||||
}
|
||||
71
crates/clusterflux-dap/src/source.rs
Normal file
71
crates/clusterflux-dap/src/source.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
pub(crate) fn infer_source_path(project: &str) -> String {
|
||||
if Path::new(project).join("src/lib.rs").is_file() {
|
||||
"src/lib.rs".to_owned()
|
||||
} else if Path::new(project).join("src/main.rs").is_file() {
|
||||
"src/main.rs".to_owned()
|
||||
} else if Path::new(project).join("src/build.rs").is_file() {
|
||||
"src/build.rs".to_owned()
|
||||
} else {
|
||||
"src/main.rs".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_name(source_path: &str) -> &str {
|
||||
Path::new(source_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(source_path)
|
||||
}
|
||||
|
||||
pub(crate) fn stack_source_path(state: &AdapterState) -> String {
|
||||
normalized_source_path(&state.project, &state.source_path)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn source_paths_match(project: &str, configured: &str, requested: &str) -> bool {
|
||||
normalized_source_path(project, configured) == normalized_source_path(project, requested)
|
||||
}
|
||||
|
||||
pub(crate) fn source_response(state: &AdapterState, request: &Value) -> Result<Value> {
|
||||
let source_path = request
|
||||
.get("arguments")
|
||||
.and_then(|arguments| arguments.get("source"))
|
||||
.and_then(|source| source.get("path"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&state.source_path);
|
||||
let content = fs::read_to_string(resolve_source_path(&state.project, source_path))?;
|
||||
Ok(json!({
|
||||
"content": content,
|
||||
"mimeType": "text/x-rustsrc",
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_source_path(project: &str, source_path: &str) -> PathBuf {
|
||||
let path = Path::new(source_path);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
Path::new(project).join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_source_path(project: &str, source_path: &str) -> PathBuf {
|
||||
let resolved = resolve_source_path(project, source_path);
|
||||
let absolute = if resolved.is_absolute() {
|
||||
resolved
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| Path::new(".").to_path_buf())
|
||||
.join(resolved)
|
||||
};
|
||||
absolute.canonicalize().unwrap_or(absolute)
|
||||
}
|
||||
1009
crates/clusterflux-dap/src/tests.rs
Normal file
1009
crates/clusterflux-dap/src/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
764
crates/clusterflux-dap/src/variables.rs
Normal file
764
crates/clusterflux-dap/src/variables.rs
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
use clusterflux_core::{Digest, TaskInstanceId};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::breakpoints::parse_rust_function_name;
|
||||
use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
|
||||
use crate::virtual_model::{AdapterState, VirtualThread};
|
||||
|
||||
pub(crate) fn variables_response(state: &AdapterState, reference: i64) -> Value {
|
||||
let Some(thread) = state.threads.values().find(|thread| {
|
||||
thread.locals_ref == reference
|
||||
|| thread.wasm_locals_ref == reference
|
||||
|| thread.args_ref == reference
|
||||
|| thread.runtime_ref == reference
|
||||
|| thread.output_ref == reference
|
||||
|| thread.target_ref == reference
|
||||
|| thread.vfs_ref == reference
|
||||
|| thread.command_ref == reference
|
||||
}) else {
|
||||
return json!({ "variables": [] });
|
||||
};
|
||||
|
||||
if thread.locals_ref == reference {
|
||||
return json!({ "variables": source_locals_variables(state, thread) });
|
||||
}
|
||||
|
||||
if thread.wasm_locals_ref == reference {
|
||||
return json!({ "variables": wasm_frame_local_variables(state, thread) });
|
||||
}
|
||||
|
||||
if thread.args_ref == reference {
|
||||
let mut variables = thread
|
||||
.runtime_task_args
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"type": "task-argument",
|
||||
"variablesReference": 0,
|
||||
})
|
||||
})
|
||||
.chain(thread.runtime_handles.iter().map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"type": "runtime-handle",
|
||||
"variablesReference": 0,
|
||||
})
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
if variables.is_empty() {
|
||||
variables.push(json!({
|
||||
"name": "runtime-boundary-diagnostic",
|
||||
"value": "the frozen runtime participant reported no task arguments or handles at this probe",
|
||||
"type": "diagnostic",
|
||||
"variablesReference": 0,
|
||||
}));
|
||||
}
|
||||
return json!({ "variables": variables });
|
||||
}
|
||||
|
||||
if thread.target_ref == reference {
|
||||
let runtime_backed =
|
||||
state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated;
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "task",
|
||||
"value": thread.task.to_string(),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "attempt",
|
||||
"value": thread.attempt_id,
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "environment",
|
||||
"value": if runtime_backed { thread.runtime_environment.as_deref().unwrap_or("unknown") } else { task_environment(thread) },
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"value": if thread.id == MAIN_THREAD { "wasm entrypoint" } else { "wasm task" },
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "required_capability",
|
||||
"value": if runtime_backed { "not reported by the frozen node participant" } else if thread.id == MAIN_THREAD { "none" } else { "Command" },
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if thread.vfs_ref == reference {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "runtime-vfs-diagnostic",
|
||||
"value": thread.runtime_vfs_checkpoint.as_deref().unwrap_or("the coordinator has no current VFS checkpoint identity"),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "reported_artifact_path",
|
||||
"value": state.runtime_artifact_path.as_deref().unwrap_or("unavailable"),
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "/vfs/artifacts",
|
||||
"value": artifact_handle_value(state, thread),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "/vfs/sources",
|
||||
"value": format!("source://local-checkout/{}", project_snapshot_suffix(&state.project)),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "/vfs/blobs",
|
||||
"value": blob_digest(state, thread),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "durability",
|
||||
"value": "best-effort until explicitly exported or stored",
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if thread.runtime_ref == reference {
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "virtual_process_id",
|
||||
"value": state.process.to_string(),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "virtual_thread",
|
||||
"value": thread.task.to_string(),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "task_attempt_id",
|
||||
"value": thread.attempt_id,
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"value": thread.runtime_node.as_deref().unwrap_or("unknown"),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "restart_compatible",
|
||||
"value": thread.restart_compatible.map_or("unknown".to_owned(), |value| value.to_string()),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "debug_epoch",
|
||||
"value": state.epoch,
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"value": format!("{:?}", thread.state),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "runtime_backend",
|
||||
"value": format!("{:?}", state.runtime_backend),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "coordinator_task_events",
|
||||
"value": state.runtime_event_count,
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "command_status",
|
||||
"value": thread.runtime_command_status.as_deref().unwrap_or(&state.command_status),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "command_spec",
|
||||
"value": command_spec_summary(state, thread),
|
||||
"variablesReference": if thread.runtime_command_status.is_some() { thread.command_ref } else { 0 }
|
||||
},
|
||||
{
|
||||
"name": "stdout_tail",
|
||||
"value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "stderr_tail",
|
||||
"value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"),
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if thread.command_ref == reference {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "status",
|
||||
"value": thread.runtime_command_status.as_deref().unwrap_or("no native command state was reported by this participant"),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "command-spec-diagnostic",
|
||||
"value": "the node debug snapshot does not yet report native command program, arguments, or capability requirements",
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "program",
|
||||
"value": command_program(thread),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "args",
|
||||
"value": command_args_summary(state, thread),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "required_capability",
|
||||
"value": if thread.id == MAIN_THREAD { "none" } else { "Command" },
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"value": state.command_status,
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
let mut variables = vec![
|
||||
json!({
|
||||
"name": "stdout_tail",
|
||||
"value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"),
|
||||
"variablesReference": 0
|
||||
}),
|
||||
json!({
|
||||
"name": "stderr_tail",
|
||||
"value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"),
|
||||
"variablesReference": 0
|
||||
}),
|
||||
];
|
||||
variables.extend(
|
||||
thread
|
||||
.recent_output
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, output)| {
|
||||
json!({
|
||||
"name": format!("line_{index}"),
|
||||
"value": output,
|
||||
"variablesReference": 0
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
json!({ "variables": variables })
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct SourceLocal {
|
||||
name: String,
|
||||
line: i64,
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
fn source_locals_variables(state: &AdapterState, thread: &VirtualThread) -> Vec<Value> {
|
||||
let (locals, diagnostic) = source_locals_for_thread(state, thread);
|
||||
let mut variables = locals
|
||||
.into_iter()
|
||||
.map(|local| {
|
||||
let has_value = local.value.is_some();
|
||||
let value = local.value.unwrap_or_else(|| {
|
||||
format!(
|
||||
"cannot be inspected: source-level local from line {} is known, but DWARF/runtime value snapshot is unavailable",
|
||||
local.line
|
||||
)
|
||||
});
|
||||
json!({
|
||||
"name": local.name,
|
||||
"value": value,
|
||||
"type": if has_value { "clusterflux-source-local" } else { "unavailable-local" },
|
||||
"variablesReference": 0
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
variables.push(json!({
|
||||
"name": "unavailable-local-diagnostic",
|
||||
"value": diagnostic,
|
||||
"type": "unavailable-local",
|
||||
"variablesReference": 0
|
||||
}));
|
||||
variables
|
||||
}
|
||||
|
||||
fn wasm_frame_local_variables(_state: &AdapterState, thread: &VirtualThread) -> Vec<Value> {
|
||||
if thread.wasm_local_values.is_empty() {
|
||||
return vec![json!({
|
||||
"name": "wasm-local-diagnostic",
|
||||
"value": "the frozen node participant did not report inspectable Wasm frame locals at this safepoint",
|
||||
"type": "unavailable-local",
|
||||
"variablesReference": 0
|
||||
})];
|
||||
}
|
||||
thread
|
||||
.wasm_local_values
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"type": "wasm-frame-local",
|
||||
"variablesReference": 0
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_locals_for_thread(
|
||||
state: &AdapterState,
|
||||
thread: &VirtualThread,
|
||||
) -> (Vec<SourceLocal>, String) {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated && thread.line <= 0
|
||||
{
|
||||
return (
|
||||
Vec::new(),
|
||||
"cannot be inspected: the coordinator has no current source location for this attempt"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
let source_path = crate::source::resolve_source_path(&state.project, &state.source_path);
|
||||
let Ok(source) = fs::read_to_string(&source_path) else {
|
||||
return (
|
||||
Vec::new(),
|
||||
format!(
|
||||
"cannot be inspected: source file `{}` is unavailable; task args and handles remain visible",
|
||||
source_path.display()
|
||||
),
|
||||
);
|
||||
};
|
||||
let lines = source.lines().collect::<Vec<_>>();
|
||||
if lines.is_empty() {
|
||||
return (
|
||||
Vec::new(),
|
||||
"cannot be inspected: source file is empty; task args and handles remain visible"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
let current = usize::try_from(thread.line)
|
||||
.ok()
|
||||
.and_then(|line| line.checked_sub(1))
|
||||
.unwrap_or(0)
|
||||
.min(lines.len() - 1);
|
||||
let function_start = lines[..=current]
|
||||
.iter()
|
||||
.rposition(|line| parse_rust_function_name(line).is_some())
|
||||
.unwrap_or(0);
|
||||
let mut locals = Vec::new();
|
||||
let mut runtime_values = BTreeMap::new();
|
||||
let mut index = function_start;
|
||||
while index <= current {
|
||||
let Some(name) = parse_let_binding_name(lines[index]) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let line = (index + 1) as i64;
|
||||
let mut statement = lines[index].to_owned();
|
||||
while !statement.contains(';') && index < current {
|
||||
index += 1;
|
||||
statement.push('\n');
|
||||
statement.push_str(lines[index]);
|
||||
}
|
||||
|
||||
let runtime_value =
|
||||
infer_clusterflux_source_local_value(state, &statement, &runtime_values);
|
||||
if let Some(value) = runtime_value.clone() {
|
||||
runtime_values.insert(name.clone(), value);
|
||||
}
|
||||
locals.push(SourceLocal {
|
||||
name,
|
||||
line,
|
||||
value: runtime_value.map(|value| value.display(state)),
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
let diagnostic = if locals.is_empty() {
|
||||
"cannot be inspected: no source-level `let` locals are in scope for this frame; task args and handles remain visible"
|
||||
.to_owned()
|
||||
} else if locals.iter().any(|local| local.value.is_some()) {
|
||||
"selected source-level Clusterflux API locals are populated from virtual-thread runtime state; non-Clusterflux locals remain best-effort until DWARF value snapshots are available"
|
||||
.to_owned()
|
||||
} else {
|
||||
"cannot be inspected: selected source-level local names are best-effort until DWARF/runtime value snapshots are available"
|
||||
.to_owned()
|
||||
};
|
||||
(locals, diagnostic)
|
||||
}
|
||||
|
||||
fn parse_let_binding_name(line: &str) -> Option<String> {
|
||||
let rest = line.trim_start().strip_prefix("let ")?;
|
||||
let rest = rest
|
||||
.trim_start()
|
||||
.strip_prefix("mut ")
|
||||
.unwrap_or(rest)
|
||||
.trim_start();
|
||||
let name = rest
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
|
||||
.collect::<String>();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum SourceLocalRuntimeValue {
|
||||
SourceSnapshot(String),
|
||||
TaskHandle {
|
||||
task: TaskInstanceId,
|
||||
thread_id: i64,
|
||||
env: String,
|
||||
state: String,
|
||||
},
|
||||
ThreadId(i64),
|
||||
Artifact(String),
|
||||
}
|
||||
|
||||
impl SourceLocalRuntimeValue {
|
||||
fn display(&self, _state: &AdapterState) -> String {
|
||||
match self {
|
||||
SourceLocalRuntimeValue::SourceSnapshot(digest) => {
|
||||
format!("SourceSnapshot {{ digest = \"{digest}\" }}")
|
||||
}
|
||||
SourceLocalRuntimeValue::TaskHandle {
|
||||
task,
|
||||
thread_id,
|
||||
env,
|
||||
state,
|
||||
} => format!(
|
||||
"TaskHandle {{ task = \"{task}\", virtual_thread_id = {thread_id}, env = \"{env}\", state = {state} }}"
|
||||
),
|
||||
SourceLocalRuntimeValue::ThreadId(thread_id) => thread_id.to_string(),
|
||||
SourceLocalRuntimeValue::Artifact(artifact) => {
|
||||
format!("Artifact {{ id = \"{artifact}\" }}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_clusterflux_source_local_value(
|
||||
state: &AdapterState,
|
||||
statement: &str,
|
||||
runtime_values: &BTreeMap<String, SourceLocalRuntimeValue>,
|
||||
) -> Option<SourceLocalRuntimeValue> {
|
||||
if statement.contains("prepare_source_snapshot()") {
|
||||
return Some(SourceLocalRuntimeValue::SourceSnapshot(
|
||||
source_snapshot_digest_from_project(state).unwrap_or_else(|| {
|
||||
format!(
|
||||
"source://local-checkout/{}",
|
||||
project_snapshot_suffix(&state.project)
|
||||
)
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(task_function) = extract_spawn_task_function(statement) {
|
||||
let task_name = extract_string_argument(statement, ".name(");
|
||||
let spawned_thread =
|
||||
thread_for_spawn_statement(state, &task_function, task_name.as_deref())?;
|
||||
return Some(SourceLocalRuntimeValue::TaskHandle {
|
||||
task: spawned_thread.task.clone(),
|
||||
thread_id: spawned_thread.id,
|
||||
env: extract_env_name(statement)
|
||||
.unwrap_or_else(|| task_environment(spawned_thread).to_owned()),
|
||||
state: format!("{:?}", spawned_thread.state),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(receiver) = receiver_before_method(statement, ".virtual_thread_id()") {
|
||||
if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) =
|
||||
runtime_values.get(&receiver)
|
||||
{
|
||||
return Some(SourceLocalRuntimeValue::ThreadId(*thread_id));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(receiver) = receiver_before_method(statement, ".join()") {
|
||||
if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) =
|
||||
runtime_values.get(&receiver)
|
||||
{
|
||||
if let Some(thread) = state.threads.get(thread_id) {
|
||||
return Some(SourceLocalRuntimeValue::Artifact(artifact_handle_value(
|
||||
state, thread,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_spawn_task_function(statement: &str) -> Option<String> {
|
||||
for marker in [
|
||||
"clusterflux::spawn::async_task(",
|
||||
"clusterflux::spawn::task(",
|
||||
] {
|
||||
if let Some(args) = extract_call_arguments(statement, marker) {
|
||||
return args.first().cloned();
|
||||
}
|
||||
}
|
||||
for marker in [
|
||||
"clusterflux::spawn::async_task_with_arg(",
|
||||
"clusterflux::spawn::task_with_arg(",
|
||||
] {
|
||||
if let Some(args) = extract_call_arguments(statement, marker) {
|
||||
return args.get(1).cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn source_snapshot_digest_from_project(state: &AdapterState) -> Option<String> {
|
||||
let source = fs::read_to_string(crate::source::resolve_source_path(
|
||||
&state.project,
|
||||
&state.source_path,
|
||||
))
|
||||
.ok()?;
|
||||
let digest_marker = source.find("digest:")?;
|
||||
let after_digest = &source[digest_marker..];
|
||||
let first_quote = after_digest.find('"')? + 1;
|
||||
let rest = &after_digest[first_quote..];
|
||||
let end_quote = rest.find('"')?;
|
||||
Some(rest[..end_quote].to_owned())
|
||||
}
|
||||
|
||||
fn extract_call_arguments(statement: &str, marker: &str) -> Option<Vec<String>> {
|
||||
let start = statement.find(marker)? + marker.len();
|
||||
let rest = &statement[start..];
|
||||
let mut args = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut depth = 0_i32;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for ch in rest.chars() {
|
||||
if in_string {
|
||||
current.push(ch);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => {
|
||||
in_string = true;
|
||||
current.push(ch);
|
||||
}
|
||||
'(' | '[' | '{' => {
|
||||
depth += 1;
|
||||
current.push(ch);
|
||||
}
|
||||
')' => {
|
||||
if depth == 0 {
|
||||
let value = current.trim();
|
||||
if !value.is_empty() {
|
||||
args.push(value.to_owned());
|
||||
}
|
||||
return (!args.is_empty()).then_some(args);
|
||||
}
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
']' | '}' => {
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
',' if depth == 0 => {
|
||||
let value = current.trim();
|
||||
if !value.is_empty() {
|
||||
args.push(value.to_owned());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_string_argument(statement: &str, marker: &str) -> Option<String> {
|
||||
let start = statement.find(marker)? + marker.len();
|
||||
let rest = &statement[start..];
|
||||
let quoted = rest.strip_prefix('"')?;
|
||||
let end = quoted.find('"')?;
|
||||
Some(quoted[..end].to_owned())
|
||||
}
|
||||
|
||||
fn extract_env_name(statement: &str) -> Option<String> {
|
||||
if statement.contains("windows_env") || statement.contains("env!(\"windows\")") {
|
||||
return Some("windows".to_owned());
|
||||
}
|
||||
if statement.contains("linux_command_env") || statement.contains("env!(\"linux-command\")") {
|
||||
return Some("linux-command".to_owned());
|
||||
}
|
||||
if statement.contains("linux_env") || statement.contains("env!(\"linux\")") {
|
||||
return Some("linux".to_owned());
|
||||
}
|
||||
if statement.contains("coordinator_env") || statement.contains("env!(\"coordinator\")") {
|
||||
return Some("coordinator".to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn receiver_before_method(statement: &str, method: &str) -> Option<String> {
|
||||
let before = statement.split(method).next()?.trim_end();
|
||||
let receiver = before
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
(!receiver.is_empty()).then_some(receiver)
|
||||
}
|
||||
|
||||
fn thread_for_spawn_statement<'a>(
|
||||
state: &'a AdapterState,
|
||||
task_function: &str,
|
||||
task_name: Option<&str>,
|
||||
) -> Option<&'a VirtualThread> {
|
||||
if let Some(task_name) = task_name {
|
||||
if let Some(thread) = state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| thread.name == task_name)
|
||||
{
|
||||
return Some(thread);
|
||||
}
|
||||
}
|
||||
let normalized = task_function.replace('_', "-");
|
||||
state.threads.values().find(|thread| {
|
||||
thread.task.as_str() == normalized
|
||||
|| (thread.id == PACKAGE_THREAD && normalized == "package-release")
|
||||
})
|
||||
}
|
||||
|
||||
fn task_environment(thread: &VirtualThread) -> &'static str {
|
||||
match thread.id {
|
||||
WINDOWS_THREAD => "windows",
|
||||
MAIN_THREAD => "coordinator",
|
||||
PACKAGE_THREAD => "coordinator",
|
||||
LINUX_THREAD => "linux",
|
||||
_ => "unconstrained",
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact_handle_value(state: &AdapterState, thread: &VirtualThread) -> String {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return state.runtime_artifact_path.clone().unwrap_or_else(|| {
|
||||
"unavailable: runtime participant did not report an artifact handle".to_owned()
|
||||
});
|
||||
}
|
||||
if thread.id == LINUX_THREAD {
|
||||
return state.runtime_artifact_path.clone().unwrap_or_else(|| {
|
||||
format!("artifact://{}/{}/app.tar.zst", state.process, thread.task)
|
||||
});
|
||||
}
|
||||
if thread.id == PACKAGE_THREAD {
|
||||
return "artifact://package/release.tar.zst".to_owned();
|
||||
}
|
||||
format!("artifact://{}/{}/app.tar.zst", state.process, thread.task)
|
||||
}
|
||||
|
||||
fn blob_digest(state: &AdapterState, thread: &VirtualThread) -> String {
|
||||
Digest::from_parts([
|
||||
b"blob:v1".as_slice(),
|
||||
state.project.as_bytes(),
|
||||
thread.task.as_str().as_bytes(),
|
||||
])
|
||||
.as_str()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn command_spec_summary(state: &AdapterState, thread: &VirtualThread) -> String {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return thread
|
||||
.runtime_command_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "no native command state reported".to_owned());
|
||||
}
|
||||
format!(
|
||||
"{} {} ({})",
|
||||
command_program(thread),
|
||||
command_args_summary(state, thread),
|
||||
state.command_status
|
||||
)
|
||||
}
|
||||
|
||||
fn command_program(thread: &VirtualThread) -> &'static str {
|
||||
if thread.id == MAIN_THREAD {
|
||||
"coordinator-side wasm"
|
||||
} else {
|
||||
"cargo"
|
||||
}
|
||||
}
|
||||
|
||||
fn command_args_summary(state: &AdapterState, thread: &VirtualThread) -> String {
|
||||
if thread.id == MAIN_THREAD {
|
||||
return format!("entry={}", state.entry);
|
||||
}
|
||||
format!("test --quiet --manifest-path {}/Cargo.toml", state.project)
|
||||
}
|
||||
|
||||
fn output_tail_display(tail: &str, bytes: u64, truncated: bool, stream: &str) -> String {
|
||||
if tail.is_empty() {
|
||||
return format!("no {stream} captured ({bytes} bytes)");
|
||||
}
|
||||
if truncated {
|
||||
format!("{tail}\n... truncated")
|
||||
} else {
|
||||
tail.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn project_snapshot_suffix(project: &str) -> String {
|
||||
Digest::from_parts([b"source-snapshot:v1".as_slice(), project.as_bytes()])
|
||||
.as_str()
|
||||
.trim_start_matches("sha256:")
|
||||
.chars()
|
||||
.take(12)
|
||||
.collect()
|
||||
}
|
||||
89
crates/clusterflux-dap/src/view_state.rs
Normal file
89
crates/clusterflux-dap/src/view_state.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::virtual_model::{AdapterState, RuntimeLaunchRecord};
|
||||
|
||||
pub(crate) fn normalize_coordinator_endpoint(endpoint: &str) -> String {
|
||||
endpoint.trim().trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn write_view_state(state: &AdapterState, record: &RuntimeLaunchRecord) -> Result<()> {
|
||||
let dir = Path::new(&state.project).join(".clusterflux");
|
||||
fs::create_dir_all(&dir)?;
|
||||
let node_status = if record.placed_task_launched {
|
||||
if record.status_code == Some(0) {
|
||||
"completed"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
} else {
|
||||
"not-required-yet"
|
||||
};
|
||||
let process_status = if record.placed_task_launched {
|
||||
if record.status_code == Some(0) {
|
||||
"completed"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
} else {
|
||||
"running"
|
||||
};
|
||||
let log_message = if record.placed_task_launched {
|
||||
"node task output was staged as a VFS artifact"
|
||||
} else {
|
||||
"coordinator-side virtual process started; capability task not launched"
|
||||
};
|
||||
let artifact_status = if record.placed_task_launched {
|
||||
"best-effort retained on the attached node"
|
||||
} else {
|
||||
"not produced until a capability task is placed"
|
||||
};
|
||||
let view_state = json!({
|
||||
"nodes": [{
|
||||
"id": record.node,
|
||||
"status": node_status,
|
||||
"capabilities": format!("connected to {}", record.coordinator),
|
||||
}],
|
||||
"processes": [{
|
||||
"id": state.process.as_str(),
|
||||
"status": process_status,
|
||||
"entry": state.entry,
|
||||
}],
|
||||
"logs": [{
|
||||
"task": "compile-linux",
|
||||
"message": log_message,
|
||||
"bytes": format!("stdout={} stderr={}", record.stdout_bytes, record.stderr_bytes),
|
||||
}],
|
||||
"artifacts": [{
|
||||
"path": record.artifact_path.clone().unwrap_or_else(|| "/vfs/artifacts/dap-output.txt".to_owned()),
|
||||
"status": artifact_status,
|
||||
"size": "reported by node",
|
||||
}],
|
||||
"inspector": [
|
||||
{
|
||||
"label": "Runtime backend",
|
||||
"value": state.runtime_backend.description(),
|
||||
},
|
||||
{
|
||||
"label": "Coordinator",
|
||||
"value": record.coordinator,
|
||||
},
|
||||
{
|
||||
"label": "Node report",
|
||||
"value": record.node_report.to_string(),
|
||||
},
|
||||
{
|
||||
"label": "Task events",
|
||||
"value": record.task_events.to_string(),
|
||||
}
|
||||
]
|
||||
});
|
||||
fs::write(
|
||||
dir.join("views.json"),
|
||||
serde_json::to_string_pretty(&view_state)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
1077
crates/clusterflux-dap/src/virtual_model.rs
Normal file
1077
crates/clusterflux-dap/src/virtual_model.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue