Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,45 @@ jobs:
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/detect-code-changes.mjs

# === VERSION CHANGE CHECK ===
# Prohibit manual version changes in package.json - versions should only be changed by CI/CD
version-check:
name: Check for Manual Version Changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check for version changes in package.json
run: |
# Skip check for automated release PRs
if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-js-"* ]]; then
echo "Skipping version check for automated release PR"
exit 0
fi

# Get the diff for package.json
VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- js/package.json | grep -E '^\+.*"version"' || true)

if [ -n "$VERSION_DIFF" ]; then
echo "::error::Manual version change detected in js/package.json"
echo ""
echo "Version changes in package.json are prohibited in pull requests."
echo "Versions are managed automatically by the CI/CD pipeline using changesets."
echo ""
echo "To request a release:"
echo " 1. Add a changeset file describing your changes"
echo " 2. The release workflow will automatically bump the version when merged"
echo ""
echo "Detected change:"
echo "$VERSION_DIFF"
exit 1
fi

echo "No manual version changes detected - check passed"

# === CHANGESET CHECK ===
changeset-check:
name: Check for Changesets
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,39 @@ jobs:
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/detect-code-changes.mjs

# === VERSION CHANGE CHECK ===
# Prohibit manual version changes in Cargo.toml - versions should only be changed by CI/CD
version-check:
name: Check for Manual Version Changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check for version changes in Cargo.toml
run: |
# Get the diff for Cargo.toml
VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- rust/Cargo.toml | grep -E '^\+version\s*=' || true)

if [ -n "$VERSION_DIFF" ]; then
echo "::error::Manual version change detected in rust/Cargo.toml"
echo ""
echo "Version changes in Cargo.toml are prohibited in pull requests."
echo "Versions are managed automatically by the CI/CD pipeline using changelog fragments."
echo ""
echo "To request a release:"
echo " 1. Add a changelog fragment in rust/changelog.d/ describing your changes"
echo " 2. The release workflow will automatically bump the version when merged"
echo ""
echo "Detected change:"
echo "$VERSION_DIFF"
exit 1
fi

echo "No manual version changes detected - check passed"

# === CHANGELOG CHECK ===
changelog:
name: Changelog Fragment Check
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ By default, all isolation environments automatically exit after command completi
│ │ Execution │ │ stdout/stderr│ │ Temp Log File │ │
│ └─────────────┘ └──────────────┘ └───────────────────┘ │
│ │
│ Console Output Format (Status Spine): │
│ Console Output Format (Timeline): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ session abc-123-def-456 │ │
│ │ │ start 2024-01-15 10:30:45 │ │
Expand Down
27 changes: 24 additions & 3 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,35 @@ Note: `--auto-remove-docker-container` is only valid with `--isolated docker` an

## Output Format

The output uses a "status spine" format that is width-independent, lossless, and works uniformly in TTY, tmux, SSH, CI, and log files.
The output uses a "timeline" format that is width-independent, lossless, and works uniformly in TTY, tmux, SSH, CI, and log files.

Format conventions:
- `│` prefix → tool metadata
- `$` prefix → executed command
- `│` prefix → tool metadata (timeline marker)
- `$` prefix → executed command (can be virtual command for setup steps or user command)
- No prefix → program output (stdout/stderr)
- `✓` / `✗` → result marker (success/failure)

### Virtual Commands

When running commands in isolation environments that require setup steps (like Docker pulling an image), the timeline shows these as separate virtual commands. This makes the execution flow transparent:

```
$ docker pull alpine:latest ← virtual command (setup step)

Unable to find image 'alpine:latest' locally
... pull output ...

$ echo hi ← user command

hi

```

Virtual commands are only displayed when the setup step actually occurs (e.g., `docker pull` is shown only if the image needs to be downloaded).

### Success Case

```
Expand Down
24 changes: 24 additions & 0 deletions js/.changeset/issue-70-timeline-virtual-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'start-command': minor
---

feat: Rename spine to timeline, add virtual command visualization for Docker

This release updates the internal terminology from "spine" to "timeline" for the status output format, and adds automatic visualization of Docker image pull operations as virtual commands.

Timeline terminology changes:

- The `│` prefixed output format is now consistently referred to as "timeline" format throughout the codebase
- All deprecated "spine" names remain available for backwards compatibility
- API changes are reflected in the Rust library (no breaking changes in JS)

Virtual command visualization for Docker:

- When Docker isolation requires pulling an image, it now appears as a separate `$ docker pull <image>` command
- Pull output is streamed in real-time with success (✓) or failure (✗) markers
- Only shown when the image actually needs to be pulled (not when using cached images)
- Provides better visibility into what's happening during Docker-based command execution

Version bumped to 0.20.0 to match the Rust library version.

Fixes #70
19 changes: 19 additions & 0 deletions rust/changelog.d/70.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
feat: Rename spine to timeline, add virtual command visualization for Docker

- Renamed "spine" terminology to "timeline" throughout the codebase
- `SPINE` constant → `TIMELINE_MARKER` (old name deprecated)
- `create_spine_line()` → `create_timeline_line()` (old name deprecated)
- `create_empty_spine_line()` → `create_empty_timeline_line()` (old name deprecated)
- Added virtual command visualization for Docker image pulls
- When Docker isolation requires pulling an image, it's shown as `$ docker pull <image>`
- Pull output is streamed in real-time with result markers (✓/✗)
- Only displayed when image actually needs to be pulled (conditional display)
- New API additions:
- `create_virtual_command_block()` - for formatting virtual commands
- `create_virtual_command_result()` - for result markers
- `docker_image_exists()` - check if image is available locally
- `docker_pull_image()` - pull with streaming output
- `StartBlockOptions.defer_command` - defer command display for multi-step execution
- All deprecated items have backward-compatible aliases for smooth migration

Fixes #70
4 changes: 4 additions & 0 deletions rust/src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ fn run_with_isolation(
}

// Print start block with session ID and isolation info
// For Docker isolation, defer the command display since virtual commands may be shown first
let is_docker_isolation = environment == Some("docker");
let extra_lines_refs: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect();
println!(
"{}",
Expand All @@ -537,6 +539,7 @@ fn run_with_isolation(
},
style: None,
width: None,
defer_command: is_docker_isolation,
})
);
println!();
Expand Down Expand Up @@ -667,6 +670,7 @@ fn run_direct(
extra_lines: None,
style: None,
width: None,
defer_command: false,
})
);
println!();
Expand Down
94 changes: 94 additions & 0 deletions rust/src/lib/isolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,83 @@ pub fn run_in_ssh(command: &str, options: &IsolationOptions) -> IsolationResult
}
}

/// Check if a Docker image exists locally
pub fn docker_image_exists(image: &str) -> bool {
Command::new("docker")
.args(["image", "inspect", image])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}

/// Pull a Docker image with output streaming
/// Returns (success, output) tuple
pub fn docker_pull_image(image: &str) -> (bool, String) {
use std::io::{BufRead, BufReader};

// Print the virtual command line
println!(
"{}",
crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image))
);
println!();

let mut child = match Command::new("docker")
.args(["pull", image])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let error_msg = format!("Failed to run docker pull: {}", e);
eprintln!("{}", error_msg);
println!();
println!(
"{}",
crate::output_blocks::create_virtual_command_result(false)
);
return (false, error_msg);
}
};

let mut output = String::new();

// Read and display stdout
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
println!("{}", line);
output.push_str(&line);
output.push('\n');
}
}

// Read and display stderr
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
eprintln!("{}", line);
output.push_str(&line);
output.push('\n');
}
}

let success = child.wait().map(|s| s.success()).unwrap_or(false);

// Print result marker and separator
println!();
println!(
"{}",
crate::output_blocks::create_virtual_command_result(success)
);
println!("{}", crate::output_blocks::create_timeline_separator());

(success, output)
}

/// Run command in Docker container
pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult {
if !is_command_available("docker") {
Expand All @@ -490,13 +567,30 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu
}
};

// Check if image exists locally; if not, pull it as a virtual command
if !docker_image_exists(&image) {
let (pull_success, _pull_output) = docker_pull_image(&image);
if !pull_success {
return IsolationResult {
success: false,
message: format!("Failed to pull Docker image: {}", image),
exit_code: Some(1),
..Default::default()
};
}
}

let container_name = options
.session
.clone()
.unwrap_or_else(|| generate_session_name(Some("docker")));

let (_, _) = get_shell();

// Print the user command (this appears after any virtual commands like docker pull)
println!("{}", crate::output_blocks::create_command_line(command));
println!();

if options.detached {
let effective_command = if options.keep_alive {
format!("{}; exec /bin/sh", command)
Expand Down
21 changes: 14 additions & 7 deletions rust/src/lib/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,23 @@ pub use execution_store::{
};
pub use failure_handler::{handle_failure, Config as FailureConfig};
pub use isolation::{
create_log_footer, create_log_header, create_log_path, get_default_docker_image, get_timestamp,
is_command_available, run_as_isolated_user, run_isolated, write_log_file, IsolationOptions,
IsolationResult, LogHeaderParams,
create_log_footer, create_log_header, create_log_path, docker_image_exists, docker_pull_image,
get_default_docker_image, get_timestamp, is_command_available, run_as_isolated_user,
run_isolated, write_log_file, IsolationOptions, IsolationResult, LogHeaderParams,
};
#[allow(deprecated)]
pub use output_blocks::{
// Status spine format API
// Timeline format API (formerly "status spine")
create_command_line,
create_empty_spine_line,
create_empty_spine_line, // deprecated, use create_empty_timeline_line
create_empty_timeline_line,
create_finish_block,
create_spine_line,
create_spine_line, // deprecated, use create_timeline_line
create_start_block,
create_timeline_line,
create_timeline_separator,
create_virtual_command_block,
create_virtual_command_result,
escape_for_links_notation,
format_duration,
format_value_for_links_notation,
Expand All @@ -44,8 +50,9 @@ pub use output_blocks::{
IsolationMetadata,
StartBlockOptions,
FAILURE_MARKER,
SPINE,
SPINE, // deprecated, use TIMELINE_MARKER
SUCCESS_MARKER,
TIMELINE_MARKER,
};
pub use signal_handler::{clear_current_execution, set_current_execution, setup_signal_handlers};
pub use status_formatter::{
Expand Down
Loading
Loading