Compare commits

..

4 Commits

Author SHA1 Message Date
YeonGyu-Kim
7235260c61 fix: preserve session path in /status text output when resuming (regression from #135) 2026-04-21 14:05:22 +09:00
YeonGyu-Kim
230d97a8fa fix: #137 update test fixtures to use canonical 'opus' alias after #124 validation tightening 2026-04-21 13:36:09 +09:00
YeonGyu-Kim
2b7095e4ae feat: surface active session status and session id
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-21 12:55:06 +09:00
YeonGyu-Kim
f55612ea47 feat: emit boot-scoped session id in lane events
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-21 12:51:13 +09:00
19 changed files with 431 additions and 3009 deletions

104
README.md
View File

@@ -1,13 +1,5 @@
# Claw Code
<p align="center">
<strong>188K GitHub stars and climbing.</strong>
</p>
<p align="center">
<strong>Rust-native agent execution for people who want speed, control, and a real terminal.</strong>
</p>
<p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
·
@@ -36,21 +28,8 @@
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
</p>
<p align="center">
Claw Code just crossed <strong>188,000 GitHub stars</strong>. This repo is the public Rust implementation of the <code>claw</code> CLI agent harness, built in the open with the UltraWorkers community.
</p>
<p align="center">
The canonical implementation lives in <a href="./rust/">rust/</a>, and the current source of truth for this repository is <strong>ultraworkers/claw-code</strong>.
</p>
## 188K and climbing
Thanks to everyone who starred, tested, reviewed, and pushed the project forward. Claw Code is focused on a straightforward promise: a fast local-first CLI agent runtime with native tools, inspectable behavior, and a Rust workspace that stays close to the metal.
- Native Rust workspace and CLI binary under [`rust/`](./rust)
- Local-first workflows for prompts, sessions, tooling, and parity validation
- Open development across the broader UltraWorkers ecosystem
Claw Code is the public Rust implementation of the `claw` CLI agent harness.
The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**.
> [!IMPORTANT]
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
@@ -119,87 +98,10 @@ export ANTHROPIC_API_KEY="sk-ant-..."
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
## Post-build: locate the binary and verify
After running `cargo build --workspace`, the `claw` binary is built but **not** automatically installed to your system. Here's where to find it and how to verify the build succeeded.
### Binary location
After `cargo build --workspace` in `claw-code/rust/`:
**Debug build (default, faster compile):**
- **macOS/Linux:** `rust/target/debug/claw`
- **Windows:** `rust/target/debug/claw.exe`
**Release build (optimized, slower compile):**
- **macOS/Linux:** `rust/target/release/claw`
- **Windows:** `rust/target/release/claw.exe`
If you ran `cargo build` without `--release`, the binary is in the `debug/` folder.
### Verify the build succeeded
Test the binary directly using its path:
```bash
# macOS/Linux (debug build)
./rust/target/debug/claw --help
./rust/target/debug/claw doctor
# Windows PowerShell (debug build)
.\rust\target\debug\claw.exe --help
.\rust\target\debug\claw.exe doctor
```
If these commands succeed, the build is working. `claw doctor` is your first health check — it validates your API key, model access, and tool configuration.
### Optional: Add to PATH
If you want to run `claw` from any directory without the full path, choose one of these approaches:
**Option 1: Symlink (macOS/Linux)**
```bash
ln -s $(pwd)/rust/target/debug/claw /usr/local/bin/claw
```
Then reload your shell and test:
```bash
claw --help
```
**Option 2: Use `cargo install` (all platforms)**
Build and install to Cargo's default location (`~/.cargo/bin/`, which is usually on PATH):
```bash
# From the claw-code/rust/ directory
cargo install --path . --force
# Then from anywhere
claw --help
```
**Option 3: Update shell profile (bash/zsh)**
Add this line to `~/.bashrc` or `~/.zshrc`:
```bash
export PATH="$(pwd)/rust/target/debug:$PATH"
```
Reload your shell:
```bash
source ~/.bashrc # or source ~/.zshrc
claw --help
```
### Troubleshooting
- **"command not found: claw"** — The binary is in `rust/target/debug/claw`, but it's not on your PATH. Use the full path `./rust/target/debug/claw` or symlink/install as above.
- **"permission denied"** — On macOS/Linux, you may need `chmod +x rust/target/debug/claw` if the executable bit isn't set (rare).
- **Debug vs. release** — If the build is slow, you're in debug mode (default). Add `--release` to `cargo build` for faster runtime, but the build itself will take 510 minutes.
> [!NOTE]
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
Run the workspace test suite after verifying the binary works:
Run the workspace test suite:
```bash
cd rust

1094
ROADMAP.md

File diff suppressed because it is too large Load Diff

View File

@@ -52,26 +52,6 @@ cd rust
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
### Initialize a repository
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
```bash
cd /path/to/your/repo
./target/debug/claw init
```
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
JSON mode for scripting:
```bash
./target/debug/claw init --output-format json
```
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
### Interactive REPL
```bash
@@ -100,85 +80,6 @@ cd rust
./target/debug/claw --output-format json prompt "status"
```
### Inspect worker state
The `claw state` command reads `.claw/worker-state.json`, which is written by the interactive REPL or a one-shot prompt when a worker executes a task. This file contains the worker ID, session reference, model, and permission mode.
Prerequisite: You must run `claw` (interactive REPL) or `claw prompt <text>` at least once in the repository to produce the worker state file.
```bash
cd rust
./target/debug/claw state
```
JSON mode:
```bash
./target/debug/claw state --output-format json
```
If you run `claw state` before any worker has executed, you will see a helpful error:
```
error: no worker state file found at .claw/worker-state.json
Hint: worker state is written by the interactive REPL or a non-interactive prompt.
Run: claw # start the REPL (writes state on first turn)
Or: claw prompt <text> # run one non-interactive turn
Then rerun: claw state [--output-format json]
```
## Advanced slash commands (Interactive REPL only)
These commands are available inside the interactive REPL (`claw` with no args). They extend the assistant with workspace analysis, planning, and navigation features.
### `/ultraplan` — Deep planning with multi-step reasoning
**Purpose:** Break down a complex task into steps using extended reasoning.
```bash
# Start the REPL
claw
# Inside the REPL
/ultraplan refactor the auth module to use async/await
/ultraplan design a caching layer for database queries
/ultraplan analyze this module for performance bottlenecks
```
Output: A structured plan with numbered steps, reasoning for each step, and expected outcomes. Use this when you want the assistant to think through a problem in detail before coding.
### `/teleport` — Jump to a file or symbol
**Purpose:** Quickly navigate to a file, function, class, or struct by name.
```bash
# Jump to a symbol
/teleport UserService
/teleport authenticate_user
/teleport RequestHandler
# Jump to a file
/teleport src/auth.rs
/teleport crates/runtime/lib.rs
/teleport ./ARCHITECTURE.md
```
Output: The file content, with the requested symbol highlighted or the file fully loaded. Useful for exploring the codebase without manually navigating directories. If multiple matches exist, the assistant shows the top candidates.
### `/bughunter` — Scan for likely bugs and issues
**Purpose:** Analyze code for common pitfalls, anti-patterns, and potential bugs.
```bash
# Scan the entire workspace
/bughunter
# Scan a specific directory or file
/bughunter src/handlers
/bughunter rust/crates/runtime
/bughunter src/auth.rs
```
Output: A list of suspicious patterns with explanations (e.g., "unchecked unwrap()", "potential race condition", "missing error handling"). Each finding includes the file, line number, and suggested fix. Use this as a first pass before a full code review.
## Model and permission controls
```bash

View File

@@ -753,14 +753,14 @@ mod tests {
#[test]
fn returns_context_window_metadata_for_kimi_models() {
// kimi-k2.5
let k25_limit =
model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata");
let k25_limit = model_token_limit("kimi-k2.5")
.expect("kimi-k2.5 should have token limit metadata");
assert_eq!(k25_limit.max_output_tokens, 16_384);
assert_eq!(k25_limit.context_window_tokens, 256_000);
// kimi-k1.5
let k15_limit =
model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata");
let k15_limit = model_token_limit("kimi-k1.5")
.expect("kimi-k1.5 should have token limit metadata");
assert_eq!(k15_limit.max_output_tokens, 16_384);
assert_eq!(k15_limit.context_window_tokens, 256_000);
}
@@ -768,13 +768,11 @@ mod tests {
#[test]
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
let alias_limit =
model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits");
assert_eq!(
alias_limit.max_output_tokens,
direct_limit.max_output_tokens
);
let alias_limit = model_token_limit("kimi")
.expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5")
.expect("kimi-k2.5 should have limits");
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
assert_eq!(
alias_limit.context_window_tokens,
direct_limit.context_window_tokens

View File

@@ -2195,16 +2195,9 @@ mod tests {
#[test]
fn provider_specific_size_limits_are_correct() {
assert_eq!(
OpenAiCompatConfig::dashscope().max_request_body_bytes,
6_291_456
); // 6MB
assert_eq!(
OpenAiCompatConfig::openai().max_request_body_bytes,
104_857_600
); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
// 50MB
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
}
#[test]

View File

@@ -2554,22 +2554,11 @@ fn render_mcp_report_for(
match normalize_optional_args(args) {
None | Some("list") => {
// #144: degrade gracefully on config parse failure (same contract
// as #143 for `status`). Text mode prepends a "Config load error"
// block before the MCP list; the list falls back to empty.
match loader.load() {
Ok(runtime_config) => Ok(render_mcp_summary_report(
cwd,
runtime_config.mcp().servers(),
)),
Err(err) => {
let empty = std::collections::BTreeMap::new();
Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
render_mcp_summary_report(cwd, &empty)
))
}
}
let runtime_config = loader.load()?;
Ok(render_mcp_summary_report(
cwd,
runtime_config.mcp().servers(),
))
}
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))),
@@ -2582,19 +2571,12 @@ fn render_mcp_report_for(
if parts.next().is_some() {
return Ok(render_mcp_usage(Some(args)));
}
// #144: same degradation for `mcp show`; if config won't parse,
// the specific server lookup can't succeed, so report the parse
// error with context.
match loader.load() {
Ok(runtime_config) => Ok(render_mcp_server_report(
cwd,
server_name,
runtime_config.mcp().get(server_name),
)),
Err(err) => Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
)),
}
let runtime_config = loader.load()?;
Ok(render_mcp_server_report(
cwd,
server_name,
runtime_config.mcp().get(server_name),
))
}
Some(args) => Ok(render_mcp_usage(Some(args))),
}
@@ -2617,33 +2599,11 @@ fn render_mcp_report_json_for(
match normalize_optional_args(args) {
None | Some("list") => {
// #144: match #143's degraded envelope contract. On config parse
// failure, emit top-level `status: "degraded"` with
// `config_load_error`, empty servers[], and exit 0. On clean
// runs, the existing serializer adds `status: "ok"` below.
match loader.load() {
Ok(runtime_config) => {
let mut value =
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
}
Err(err) => {
let empty = std::collections::BTreeMap::new();
let mut value = render_mcp_summary_report_json(cwd, &empty);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("degraded".to_string()));
map.insert(
"config_load_error".to_string(),
Value::String(err.to_string()),
);
}
Ok(value)
}
}
let runtime_config = loader.load()?;
Ok(render_mcp_summary_report_json(
cwd,
runtime_config.mcp().servers(),
))
}
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
@@ -2656,29 +2616,12 @@ fn render_mcp_report_json_for(
if parts.next().is_some() {
return Ok(render_mcp_usage_json(Some(args)));
}
// #144: same degradation pattern for show action.
match loader.load() {
Ok(runtime_config) => {
let mut value = render_mcp_server_report_json(
cwd,
server_name,
runtime_config.mcp().get(server_name),
);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
}
Err(err) => Ok(serde_json::json!({
"kind": "mcp",
"action": "show",
"server": server_name,
"status": "degraded",
"config_load_error": err.to_string(),
"working_directory": cwd.display().to_string(),
})),
}
let runtime_config = loader.load()?;
Ok(render_mcp_server_report_json(
cwd,
server_name,
runtime_config.mcp().get(server_name),
))
}
Some(args) => Ok(render_mcp_usage_json(Some(args))),
}
@@ -5536,82 +5479,6 @@ mod tests {
let _ = fs::remove_dir_all(config_home);
}
#[test]
fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
// #144: mirror of #143's partial-success contract for `claw mcp`.
// Previously `mcp` hard-failed on any config parse error, hiding
// well-formed servers and forcing claws to fall back to `doctor`.
// Now `mcp` emits a degraded envelope instead: exit 0, status:
// "degraded", config_load_error populated, servers[] empty.
let _guard = env_guard();
let workspace = temp_dir("mcp-degrades-144");
let config_home = temp_dir("mcp-degrades-144-cfg");
fs::create_dir_all(workspace.join(".claw")).expect("create workspace .claw dir");
fs::create_dir_all(&config_home).expect("create config home");
// One valid server + one malformed entry missing `command`.
fs::write(
workspace.join(".claw.json"),
r#"{
"mcpServers": {
"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
"missing-command": {"args": ["arg-only-no-command"]}
}
}
"#,
)
.expect("write malformed .claw.json");
let loader = ConfigLoader::new(&workspace, &config_home);
// list action: must return Ok (not Err) with degraded envelope.
let list = render_mcp_report_json_for(&loader, &workspace, None)
.expect("mcp list should not hard-fail on config parse errors (#144)");
assert_eq!(list["kind"], "mcp");
assert_eq!(list["action"], "list");
assert_eq!(
list["status"].as_str(),
Some("degraded"),
"top-level status should be 'degraded': {list}"
);
let err = list["config_load_error"]
.as_str()
.expect("config_load_error must be a string on degraded runs");
assert!(
err.contains("mcpServers.missing-command"),
"config_load_error should name the malformed field path: {err}"
);
assert_eq!(list["configured_servers"], 0);
assert!(list["servers"].as_array().unwrap().is_empty());
// show action: should also degrade (not hard-fail).
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
.expect("mcp show should not hard-fail on config parse errors (#144)");
assert_eq!(show["kind"], "mcp");
assert_eq!(show["action"], "show");
assert_eq!(
show["status"].as_str(),
Some("degraded"),
"show action should also report status: 'degraded': {show}"
);
assert!(show["config_load_error"].is_string());
// Clean path: status: "ok", config_load_error: null.
let clean_ws = temp_dir("mcp-degrades-144-clean");
fs::create_dir_all(&clean_ws).expect("clean ws");
let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None)
.expect("clean mcp list should succeed");
assert_eq!(
clean_list["status"].as_str(),
Some("ok"),
"clean run should report status: 'ok'"
);
assert!(clean_list["config_load_error"].is_null());
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(clean_ws);
}
#[test]
fn parses_quoted_skill_frontmatter_values() {
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";

View File

@@ -172,7 +172,7 @@ async fn execute_bash_async(
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {

View File

@@ -1254,21 +1254,11 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
// #149: previously used `runtime-config-{nanos}` which collided
// under parallel `cargo test --workspace` when multiple tests
// started within the same nanosecond bucket on fast machines.
// Add process id + a monotonically-incrementing atomic counter
// so every callsite gets a provably-unique directory regardless
// of clock resolution or scheduling.
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
let pid = std::process::id();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
std::env::temp_dir().join(format!("runtime-config-{nanos}"))
}
#[test]

View File

@@ -244,6 +244,7 @@ pub struct LaneEventBuilder {
event: LaneEventName,
status: LaneEventStatus,
emitted_at: String,
session_id: Option<String>,
metadata: LaneEventMetadata,
detail: Option<String>,
failure_class: Option<LaneFailureClass>,
@@ -264,6 +265,7 @@ impl LaneEventBuilder {
event,
status,
emitted_at: emitted_at.into(),
session_id: None,
metadata: LaneEventMetadata::new(seq, provenance),
detail: None,
failure_class: None,
@@ -278,6 +280,13 @@ impl LaneEventBuilder {
self
}
/// Add boot-scoped session correlation id
#[must_use]
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
/// Add ownership info
#[must_use]
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
@@ -328,6 +337,7 @@ impl LaneEventBuilder {
event: self.event,
status: self.status,
emitted_at: self.emitted_at,
session_id: self.session_id,
failure_class: self.failure_class,
detail: self.detail,
data: self.data,
@@ -465,6 +475,8 @@ pub struct LaneEvent {
pub status: LaneEventStatus,
#[serde(rename = "emittedAt")]
pub emitted_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
pub failure_class: Option<LaneFailureClass>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -488,6 +500,7 @@ impl LaneEvent {
event,
status,
emitted_at: emitted_at.into(),
session_id: None,
failure_class: None,
detail: None,
data: None,
@@ -635,6 +648,12 @@ impl LaneEvent {
self.data = Some(data);
self
}
#[must_use]
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
}
#[must_use]
@@ -1065,6 +1084,7 @@ mod tests {
42,
EventProvenance::Test,
)
.with_session_id("boot-abc123def4567890")
.with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test"))
.with_ownership(LaneOwnership {
owner: "bot-1".to_string(),
@@ -1076,6 +1096,7 @@ mod tests {
.build();
assert_eq!(event.event, LaneEventName::Started);
assert_eq!(event.session_id.as_deref(), Some("boot-abc123def4567890"));
assert_eq!(event.metadata.seq, 42);
assert_eq!(event.metadata.provenance, EventProvenance::Test);
assert_eq!(
@@ -1105,4 +1126,34 @@ mod tests {
assert_eq!(round_trip.provenance, EventProvenance::Healthcheck);
assert_eq!(round_trip.nudge_id, Some("nudge-abc".to_string()));
}
#[test]
fn lane_event_session_id_round_trips_through_serialization() {
let event = LaneEventBuilder::new(
LaneEventName::Started,
LaneEventStatus::Running,
"2026-04-04T00:00:00Z",
1,
EventProvenance::LiveLane,
)
.with_session_id("boot-0123456789abcdef")
.build();
let json = serde_json::to_value(&event).expect("should serialize");
assert_eq!(json["session_id"], "boot-0123456789abcdef");
let round_trip: LaneEvent = serde_json::from_value(json).expect("should deserialize");
assert_eq!(
round_trip.session_id.as_deref(),
Some("boot-0123456789abcdef")
);
}
#[test]
fn lane_event_session_id_omits_field_when_absent() {
let event = LaneEvent::started("2026-04-04T00:00:00Z");
let json = serde_json::to_value(&event).expect("should serialize");
assert!(json.get("session_id").is_none());
}
}

View File

@@ -36,6 +36,7 @@ mod remote;
pub mod sandbox;
mod session;
pub mod session_control;
mod session_identity;
pub use session_control::SessionStore;
mod sse;
pub mod stale_base;
@@ -153,6 +154,9 @@ pub use session::{
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
SessionFork, SessionPromptEntry,
};
pub use session_identity::{
begin_session, current_boot_session_id, end_session, is_active_session,
};
pub use sse::{IncrementalSseParser, SseEvent};
pub use stale_base::{
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,

View File

@@ -31,19 +31,14 @@ impl SessionStore {
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
let cwd = cwd.as_ref();
// #151: canonicalize so equivalent paths (symlinks, relative vs
// absolute, /tmp vs /private/tmp on macOS) produce the same
// workspace_fingerprint. Falls back to the raw path if canonicalize
// fails (e.g. the directory doesn't exist yet).
let canonical_cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let sessions_root = canonical_cwd
let sessions_root = cwd
.join(".claw")
.join("sessions")
.join(workspace_fingerprint(&canonical_cwd));
.join(workspace_fingerprint(cwd));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: canonical_cwd,
workspace_root: cwd.to_path_buf(),
})
}
@@ -56,18 +51,14 @@ impl SessionStore {
workspace_root: impl AsRef<Path>,
) -> Result<Self, SessionControlError> {
let workspace_root = workspace_root.as_ref();
// #151: canonicalize workspace_root for consistent fingerprinting
// across equivalent path representations.
let canonical_workspace =
fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
let sessions_root = data_dir
.as_ref()
.join("sessions")
.join(workspace_fingerprint(&canonical_workspace));
.join(workspace_fingerprint(workspace_root));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: canonical_workspace,
workspace_root: workspace_root.to_path_buf(),
})
}
@@ -112,7 +103,7 @@ impl SessionStore {
candidate
} else if looks_like_path {
return Err(SessionControlError::Format(
format_missing_session_reference(reference, &self.sessions_root),
format_missing_session_reference(reference),
));
} else {
self.resolve_managed_path(reference)?
@@ -143,7 +134,7 @@ impl SessionStore {
}
}
Err(SessionControlError::Format(
format_missing_session_reference(session_id, &self.sessions_root),
format_missing_session_reference(session_id),
))
}
@@ -158,9 +149,10 @@ impl SessionStore {
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.list_sessions()?.into_iter().next().ok_or_else(|| {
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
})
self.list_sessions()?
.into_iter()
.next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
}
pub fn load_session(
@@ -521,25 +513,15 @@ fn session_id_from_path(path: &Path) -> Option<String> {
.map(ToOwned::to_owned)
}
fn format_missing_session_reference(reference: &str, sessions_root: &Path) -> String {
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
fn format_missing_session_reference(reference: &str) -> String {
format!(
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/{fingerprint_dir}/ (workspace-specific partition).\nTry `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
)
}
fn format_no_managed_sessions(sessions_root: &Path) -> String {
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
fn format_no_managed_sessions() -> String {
format!(
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible."
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
)
}
@@ -762,40 +744,6 @@ mod tests {
assert_eq!(fp_a1.len(), 16, "fingerprint must be a 16-char hex string");
}
/// #151 regression: equivalent paths (e.g. `/tmp/foo` vs `/private/tmp/foo`
/// on macOS where `/tmp` is a symlink to `/private/tmp`) must resolve to
/// the same session store. Previously they diverged because
/// `workspace_fingerprint()` hashed the raw path string. Now
/// `SessionStore::from_cwd()` canonicalizes first.
#[test]
fn session_store_from_cwd_canonicalizes_equivalent_paths() {
let base = temp_dir();
let real_dir = base.join("real-workspace");
fs::create_dir_all(&real_dir).expect("real workspace should exist");
// Build two stores via different but equivalent path representations:
// the raw path and the canonicalized path.
let raw_path = real_dir.clone();
let canonical_path = fs::canonicalize(&real_dir).expect("canonicalize ok");
let store_from_raw =
SessionStore::from_cwd(&raw_path).expect("store from raw should build");
let store_from_canonical =
SessionStore::from_cwd(&canonical_path).expect("store from canonical should build");
assert_eq!(
store_from_raw.sessions_dir(),
store_from_canonical.sessions_dir(),
"equivalent paths must produce the same sessions dir (raw={} canonical={})",
raw_path.display(),
canonical_path.display()
);
if base.exists() {
fs::remove_dir_all(base).expect("cleanup ok");
}
}
#[test]
fn session_store_from_cwd_isolates_sessions_by_workspace() {
// given
@@ -884,11 +832,6 @@ mod tests {
let workspace_b = base.join("repo-beta");
fs::create_dir_all(&workspace_a).expect("workspace a should exist");
fs::create_dir_all(&workspace_b).expect("workspace b should exist");
// #151: canonicalize so test expectations match the store's canonical
// workspace_root. Without this, the test builds sessions with a raw
// path but the store resolves to the canonical form.
let workspace_a = fs::canonicalize(&workspace_a).unwrap_or(workspace_a);
let workspace_b = fs::canonicalize(&workspace_b).unwrap_or(workspace_b);
let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build");
let legacy_root = workspace_b.join(".claw").join("sessions");
@@ -922,8 +865,6 @@ mod tests {
// given
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
// #151: canonicalize for path-representation consistency with store.
let base = fs::canonicalize(&base).unwrap_or(base);
let store = SessionStore::from_cwd(&base).expect("store should build");
let legacy_root = base.join(".claw").join("sessions");
let legacy_path = legacy_root.join("legacy-safe.jsonl");
@@ -952,8 +893,6 @@ mod tests {
// given
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
// #151: canonicalize for path-representation consistency with store.
let base = fs::canonicalize(&base).unwrap_or(base);
let store = SessionStore::from_cwd(&base).expect("store should build");
let legacy_root = base.join(".claw").join("sessions");
let legacy_path = legacy_root.join("legacy-unbound.json");

View File

@@ -0,0 +1,84 @@
use std::collections::hash_map::DefaultHasher;
use std::env;
use std::hash::{Hash, Hasher};
use std::process;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
static BOOT_SESSION_ID: OnceLock<String> = OnceLock::new();
static BOOT_SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SESSION: AtomicBool = AtomicBool::new(false);
#[must_use]
pub fn current_boot_session_id() -> &'static str {
BOOT_SESSION_ID.get_or_init(resolve_boot_session_id)
}
pub fn begin_session() {
ACTIVE_SESSION.store(true, Ordering::SeqCst);
}
pub fn end_session() {
ACTIVE_SESSION.store(false, Ordering::SeqCst);
}
#[must_use]
pub fn is_active_session() -> bool {
ACTIVE_SESSION.load(Ordering::SeqCst)
}
fn resolve_boot_session_id() -> String {
match env::var("CLAW_SESSION_ID") {
Ok(value) if !value.trim().is_empty() => value,
_ => generate_boot_session_id(),
}
}
fn generate_boot_session_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = BOOT_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut hasher = DefaultHasher::new();
process::id().hash(&mut hasher);
nanos.hash(&mut hasher);
counter.hash(&mut hasher);
format!("boot-{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::{begin_session, current_boot_session_id, end_session, is_active_session};
#[test]
fn given_current_boot_session_id_when_called_twice_then_it_is_stable() {
let first = current_boot_session_id();
let second = current_boot_session_id();
assert_eq!(first, second);
assert!(first.starts_with("boot-"));
}
#[test]
fn given_current_boot_session_id_when_inspected_then_it_is_opaque_and_non_empty() {
let session_id = current_boot_session_id();
assert!(!session_id.trim().is_empty());
assert_eq!(session_id.len(), 21);
assert!(!session_id.contains(' '));
}
#[test]
fn given_begin_and_end_session_when_checked_then_active_state_toggles() {
end_session();
assert!(!is_active_session());
begin_session();
assert!(is_active_session());
end_session();
assert!(!is_active_session());
}
}

View File

@@ -18,6 +18,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::current_boot_session_id;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -768,6 +770,7 @@ fn push_event(
#[derive(serde::Serialize)]
struct StateSnapshot<'a> {
worker_id: &'a str,
session_id: &'a str,
status: WorkerStatus,
is_ready: bool,
trust_gate_cleared: bool,
@@ -790,6 +793,7 @@ fn emit_state_file(worker: &Worker) {
let now = now_secs();
let snapshot = StateSnapshot {
worker_id: &worker.worker_id,
session_id: current_boot_session_id(),
status: worker.status,
is_ready: worker.status == WorkerStatus::ReadyForPrompt,
trust_gate_cleared: worker.trust_gate_cleared,
@@ -1449,6 +1453,10 @@ mod tests {
Some("spawning"),
"initial status should be spawning"
);
assert_eq!(
value["session_id"].as_str(),
Some(current_boot_session_id())
);
assert_eq!(value["is_ready"].as_bool(), Some(false));
// Transition to ReadyForPrompt by observing trust-cleared text

View File

@@ -27,18 +27,6 @@ impl InitStatus {
Self::Skipped => "skipped (already exists)",
}
}
/// Machine-stable identifier for structured output (#142).
/// Unlike `label()`, this never changes wording: claws can switch on
/// these values without brittle substring matching.
#[must_use]
pub(crate) fn json_tag(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -70,36 +58,6 @@ impl InitReport {
lines.push(" Next step Review and tailor the generated guidance".to_string());
lines.join("\n")
}
/// Summary constant that claws can embed in JSON output without having
/// to read it out of the human-formatted `message` string (#142).
pub(crate) const NEXT_STEP: &'static str = "Review and tailor the generated guidance";
/// Artifact names that ended in the given status. Used to build the
/// structured `created[]`/`updated[]`/`skipped[]` arrays for #142.
#[must_use]
pub(crate) fn artifacts_with_status(&self, status: InitStatus) -> Vec<String> {
self.artifacts
.iter()
.filter(|artifact| artifact.status == status)
.map(|artifact| artifact.name.to_string())
.collect()
}
/// Structured artifact list for JSON output (#142). Each entry carries
/// `name` and machine-stable `status` tag.
#[must_use]
pub(crate) fn artifact_json_entries(&self) -> Vec<serde_json::Value> {
self.artifacts
.iter()
.map(|artifact| {
serde_json::json!({
"name": artifact.name,
"status": artifact.status.json_tag(),
})
})
.collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -375,7 +333,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{initialize_repo, render_init_claude_md, InitStatus};
use super::{initialize_repo, render_init_claude_md};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -455,63 +413,6 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn artifacts_with_status_partitions_fresh_and_idempotent_runs() {
// #142: the structured JSON output needs to be able to partition
// artifacts into created/updated/skipped without substring matching
// the human-formatted `message` string.
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
let fresh = initialize_repo(&root).expect("fresh init should succeed");
let created_names = fresh.artifacts_with_status(InitStatus::Created);
assert_eq!(
created_names,
vec![
".claw/".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"fresh init should place all four artifacts in created[]"
);
assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts"
);
let second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
assert_eq!(
skipped_names,
vec![
".claw/".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"idempotent init should place all four artifacts in skipped[]"
);
assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts"
);
// artifact_json_entries() uses the machine-stable `json_tag()` which
// never changes wording (unlike `label()` which says "skipped (already exists)").
let entries = second.artifact_json_entries();
assert_eq!(entries.len(), 4);
for entry in &entries {
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
}
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_init_template_mentions_detected_python_and_nextjs_markers() {
let root = temp_dir();

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -126,63 +125,6 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_flag_with_json_output_emits_structured_json() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("compact-json");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let output = run_claw(
&workspace,
&config_home,
&home,
&base_url,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
"--output-format",
"json",
"--compact",
&prompt,
],
);
assert!(
output.status.success(),
"compact json run should succeed
stdout:
{}
stderr:
{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
assert_eq!(
parsed["message"],
"Mock streaming says hello from the parity harness."
);
assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["usage"].is_object());
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
fn run_claw(
cwd: &std::path::Path,
config_home: &std::path::Path,

View File

@@ -39,6 +39,8 @@ fn status_and_sandbox_emit_json_when_requested() {
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert_eq!(status["active_session"], false);
assert!(status["session_id"].is_null());
assert!(status["workspace"]["cwd"].as_str().is_some());
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
@@ -384,6 +386,47 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
assert!(root.join("CLAUDE.md").exists());
}
#[test]
fn status_json_surfaces_active_session_and_boot_session_id_from_worker_state() {
let root = unique_temp_dir("status-worker-state-json");
fs::create_dir_all(&root).expect("temp dir should exist");
write_worker_state_fixture(&root, "running", "boot-fixture-123");
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert_eq!(status["active_session"], true);
assert_eq!(status["session_id"], "boot-fixture-123");
}
#[test]
fn status_text_surfaces_active_session_and_boot_session_id_from_worker_state() {
let root = unique_temp_dir("status-worker-state-text");
fs::create_dir_all(&root).expect("temp dir should exist");
write_worker_state_fixture(&root, "running", "boot-fixture-456");
let output = run_claw(&root, &["status"], &[]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Session active (boot-fixture-456)"));
}
#[test]
fn worker_state_fixture_round_trips_session_id_across_status_surface() {
let root = unique_temp_dir("status-worker-state-roundtrip");
fs::create_dir_all(&root).expect("temp dir should exist");
let session_id = "boot-roundtrip-789";
write_worker_state_fixture(&root, "running", session_id);
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["active_session"], true);
assert_eq!(status["session_id"], session_id);
let raw = fs::read_to_string(root.join(".claw").join("worker-state.json"))
.expect("worker state should exist");
let state: Value = serde_json::from_str(&raw).expect("worker state should be valid json");
assert_eq!(state["session_id"], session_id);
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}
@@ -431,6 +474,26 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
upstream
}
fn write_worker_state_fixture(root: &Path, status: &str, session_id: &str) {
let claw_dir = root.join(".claw");
fs::create_dir_all(&claw_dir).expect("worker state dir should exist");
fs::write(
claw_dir.join("worker-state.json"),
serde_json::to_string_pretty(&serde_json::json!({
"worker_id": "worker-test",
"session_id": session_id,
"status": status,
"is_ready": status == "ready_for_prompt",
"trust_gate_cleared": false,
"prompt_in_flight": status == "running",
"updated_at": 1,
"seconds_since_update": 0
}))
.expect("worker state json should serialize"),
)
.expect("worker state fixture should write");
}
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
let session_path = root.join("session.jsonl");
let mut session = Session::new()

View File

@@ -180,8 +180,6 @@ fn resume_latest_restores_the_most_recent_managed_session() {
// given
let temp_dir = unique_temp_dir("resume-latest");
let project_dir = temp_dir.join("project");
fs::create_dir_all(&project_dir).expect("project dir should exist");
let project_dir = fs::canonicalize(&project_dir).unwrap_or(project_dir);
let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build");
let older_path = store.create_handle("session-older").path;
let newer_path = store.create_handle("session-newer").path;

View File

@@ -11,8 +11,8 @@ use api::{
use plugins::PluginTool;
use reqwest::blocking::Client;
use runtime::{
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
grep_search, load_system_prompt,
check_freshness, current_boot_session_id, dedupe_superseded_commit_events, edit_file,
execute_bash, glob_search, grep_search, load_system_prompt,
lsp_client::LspRegistry,
mcp_tool_bridge::McpToolRegistry,
permission_enforcer::{EnforcementResult, PermissionEnforcer},
@@ -3535,7 +3535,9 @@ where
created_at: created_at.clone(),
started_at: Some(created_at),
completed_at: None,
lane_events: vec![LaneEvent::started(iso8601_now())],
lane_events: vec![
LaneEvent::started(iso8601_now()).with_session_id(current_boot_session_id())
],
current_blocker: None,
derived_state: String::from("working"),
error: None,
@@ -3744,6 +3746,11 @@ fn persist_agent_terminal_state(
error: Option<String>,
) -> Result<(), String> {
let blocker = error.as_deref().map(classify_lane_blocker);
let session_id = manifest
.lane_events
.last()
.and_then(|event| event.session_id.clone())
.unwrap_or_else(|| current_boot_session_id().to_string());
append_agent_output(
&manifest.output_file,
&format_agent_terminal_output(status, result, blocker.as_ref(), error.as_deref()),
@@ -3758,26 +3765,31 @@ fn persist_agent_terminal_state(
if let Some(blocker) = blocker {
next_manifest
.lane_events
.push(LaneEvent::blocked(iso8601_now(), &blocker));
.push(LaneEvent::blocked(iso8601_now(), &blocker).with_session_id(session_id.clone()));
next_manifest
.lane_events
.push(LaneEvent::failed(iso8601_now(), &blocker));
.push(LaneEvent::failed(iso8601_now(), &blocker).with_session_id(session_id.clone()));
} else {
next_manifest.current_blocker = None;
let mut finished_summary = build_lane_finished_summary(&next_manifest, result);
finished_summary.data.disabled_cron_ids = disable_matching_crons(&next_manifest, result);
next_manifest.lane_events.push(
LaneEvent::finished(iso8601_now(), finished_summary.detail).with_data(
serde_json::to_value(&finished_summary.data)
.expect("lane summary metadata should serialize"),
),
LaneEvent::finished(iso8601_now(), finished_summary.detail)
.with_data(
serde_json::to_value(&finished_summary.data)
.expect("lane summary metadata should serialize"),
)
.with_session_id(session_id.clone()),
);
if let Some(provenance) = maybe_commit_provenance(result) {
next_manifest.lane_events.push(LaneEvent::commit_created(
iso8601_now(),
Some(format!("commit {}", provenance.commit)),
provenance,
));
next_manifest.lane_events.push(
LaneEvent::commit_created(
iso8601_now(),
Some(format!("commit {}", provenance.commit)),
provenance,
)
.with_session_id(session_id),
);
}
}
write_agent_manifest(&next_manifest)
@@ -7761,6 +7773,9 @@ mod tests {
assert!(manifest_contents.contains("\"status\": \"running\""));
assert_eq!(manifest_json["laneEvents"][0]["event"], "lane.started");
assert_eq!(manifest_json["laneEvents"][0]["status"], "running");
assert!(manifest_json["laneEvents"][0]["session_id"]
.as_str()
.is_some());
assert!(manifest_json["currentBlocker"].is_null());
let captured_job = captured
.lock()
@@ -7838,10 +7853,17 @@ mod tests {
completed_manifest_json["laneEvents"][0]["event"],
"lane.started"
);
let session_id = completed_manifest_json["laneEvents"][0]["session_id"]
.as_str()
.expect("startup session_id should exist");
assert_eq!(
completed_manifest_json["laneEvents"][1]["event"],
"lane.finished"
);
assert_eq!(
completed_manifest_json["laneEvents"][1]["session_id"],
session_id
);
assert_eq!(
completed_manifest_json["laneEvents"][1]["data"]["qualityFloorApplied"],
false
@@ -7854,6 +7876,10 @@ mod tests {
completed_manifest_json["laneEvents"][2]["event"],
"lane.commit.created"
);
assert_eq!(
completed_manifest_json["laneEvents"][2]["session_id"],
session_id
);
assert_eq!(
completed_manifest_json["laneEvents"][2]["data"]["commit"],
"abc1234"