diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 66f1b703..d86aa606 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -119,13 +119,15 @@ pub struct BackendWorkingDirectorySummary { pub working_directory_id: String, pub repository_id: String, #[serde(default)] - pub requested_selector: Option, + pub creation_selector: Option, + #[serde(default)] + pub creation_ref: Option, + #[serde(default)] + pub current_selector: Option, + #[serde(default)] + pub current_ref: Option, pub materializer_kind: String, #[serde(default)] - pub resolved_commit: Option, - #[serde(default)] - pub resolved_tree: Option, - #[serde(default)] pub cleanup_target: Option, pub status: String, #[serde(default)] diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 20ddab2d..b412e0c8 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -143,14 +143,20 @@ pub struct WorkingDirectoryOccupancy { pub struct WorkingDirectorySummary { pub working_directory_id: String, pub repository_id: String, + /// Selector used to create this Workdir, retained as immutable materialization evidence. #[serde(default, skip_serializing_if = "Option::is_none")] - pub requested_selector: Option, + pub creation_selector: Option, + /// Provider-specific immutable ref resolved when this Workdir was created. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + /// Selector currently observed from the materialized Workdir, when one exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + /// Provider-specific immutable ref currently observed from the materialized Workdir. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, pub materializer_kind: MaterializerKind, #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_commit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_tree: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub cleanup_target: Option, pub status: WorkingDirectoryStatusKind, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 10bcd593..56f367d6 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -40,10 +40,11 @@ impl WorkingDirectory { WorkingDirectorySummary { working_directory_id: self.id.clone(), repository_id: self.repository_id.clone(), - requested_selector: self.evidence.requested_selector.clone(), + creation_selector: self.evidence.requested_selector.clone(), + creation_ref: Some(self.evidence.resolved_commit.clone()), + current_selector: None, + current_ref: None, materializer_kind: self.materializer_kind.clone(), - resolved_commit: Some(self.evidence.resolved_commit.clone()), - resolved_tree: self.evidence.resolved_tree.clone(), cleanup_target: Some(self.cleanup_target.clone()), status: self.status.clone(), cleanliness: None, @@ -88,6 +89,9 @@ impl WorkingDirectoryBinding { } let mut summary = working_directory.status_summary(); summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active { + let (current_selector, current_ref) = binding_current_revision(self); + summary.current_selector = current_selector; + summary.current_ref = current_ref; Some(binding_cleanliness(self)) } else { Some("unknown".to_string()) @@ -171,6 +175,22 @@ fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool { source_repository_path.is_dir() } +fn binding_current_revision(binding: &WorkingDirectoryBinding) -> (Option, Option) { + let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"]) + .ok() + .filter(|value| !value.is_empty()); + if current_ref.is_none() { + return (None, None); + } + let current_selector = git_stdout( + binding.root(), + ["symbolic-ref", "--short", "--quiet", "HEAD"], + ) + .ok() + .filter(|value| !value.is_empty()); + (current_selector, current_ref) +} + fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String { match git_stdout(binding.root(), ["status", "--porcelain"]) { Ok(output) if output.is_empty() => "clean".to_string(), @@ -208,10 +228,11 @@ impl LocalGitWorktreeMaterializer { summary: WorkingDirectorySummary { working_directory_id: working_directory_id.to_string(), repository_id: "unknown".to_string(), - requested_selector: None, + creation_selector: None, + creation_ref: None, + current_selector: None, + current_ref: None, materializer_kind: MaterializerKind::LocalGitWorktree, - resolved_commit: None, - resolved_tree: None, cleanup_target: Some(WorkingDirectoryCleanupTarget { kind: "local_git_worktree".to_string(), working_directory_id: working_directory_id.to_string(), @@ -930,12 +951,39 @@ mod tests { listed[0].summary.working_directory_id, working_directory.working_directory.id ); + assert_eq!(listed[0].summary.creation_selector.as_deref(), Some("HEAD")); + assert_eq!(listed[0].summary.current_selector, None); assert_eq!( - listed[0].summary.requested_selector.as_deref(), - Some("HEAD") + listed[0].summary.current_ref, + listed[0].summary.creation_ref ); } + #[test] + fn working_directory_observes_current_selector_and_ref_without_changing_creation_evidence() { + let repo = create_clean_repo(); + let runtime_root = tempfile::tempdir().unwrap(); + let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path()); + let working_directory = materializer.create(&request(repo.path())).unwrap(); + let bound = materializer + .bind_working_directory(&working_directory.working_directory.id, None) + .unwrap(); + let initial_ref = bound.status().summary.creation_ref.expect("creation ref"); + + git(&bound.root, &["switch", "-c", "observed-branch"]); + fs::write(bound.root.join("observed.txt"), "observed\n").unwrap(); + git(&bound.root, &["add", "observed.txt"]); + git(&bound.root, &["commit", "-m", "advance workdir"]); + + let summary = materializer.list_working_directories().unwrap()[0] + .summary + .clone(); + assert_eq!(summary.creation_selector.as_deref(), Some("HEAD")); + assert_eq!(summary.creation_ref.as_deref(), Some(initial_ref.as_str())); + assert_eq!(summary.current_selector.as_deref(), Some("observed-branch")); + assert_ne!(summary.current_ref.as_deref(), Some(initial_ref.as_str())); + } + #[test] fn relative_cwd_rejects_absolute_parent_nonexistent_file_and_symlink_escape() { let repo = create_clean_repo(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 43a9b15c..15804702 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2381,12 +2381,14 @@ fn create_working_directory_for_runtime( workdir_id: workdir_id.clone(), runtime_id: runtime_id.clone(), repository_id: working_directory_request.repository.id.clone(), - selector: working_directory_request + creation_selector: working_directory_request .repository .selector .as_ref() .map(|selector| selector.as_ref().to_string()), - resolved_commit: None, + creation_ref: None, + current_selector: None, + current_ref: None, materialization_status: "pending".to_string(), cleanliness: "unknown".to_string(), created_at: now_registry_timestamp(), @@ -6264,12 +6266,14 @@ fn upsert_pending_backend_workdir( workdir_id: workdir_id.clone(), runtime_id: runtime_id.to_string(), repository_id: request.repository.id.clone(), - selector: request + creation_selector: request .repository .selector .as_ref() .map(|selector| selector.as_ref().to_string()), - resolved_commit: None, + creation_ref: None, + current_selector: None, + current_ref: None, materialization_status: "pending".to_string(), cleanliness: "unknown".to_string(), created_at: timestamp.clone(), @@ -6417,8 +6421,10 @@ fn workdir_record_from_summary( workdir_id: summary.working_directory_id.clone(), runtime_id: runtime_id.to_string(), repository_id: summary.repository_id.clone(), - selector: summary.requested_selector.clone(), - resolved_commit: summary.resolved_commit.clone(), + creation_selector: summary.creation_selector.clone(), + creation_ref: summary.creation_ref.clone(), + current_selector: summary.current_selector.clone(), + current_ref: summary.current_ref.clone(), materialization_status: match summary.status { WorkingDirectoryStatusKind::Active => "present", WorkingDirectoryStatusKind::CleanupPending => "pending", @@ -6449,11 +6455,11 @@ fn preserve_workdir_identity_for_corrupted_summary( if record.repository_id == "unknown" { record.repository_id = existing.repository_id.clone(); } - if record.selector.is_none() { - record.selector = existing.selector.clone(); + if record.creation_selector.is_none() { + record.creation_selector = existing.creation_selector.clone(); } - if record.resolved_commit.is_none() { - record.resolved_commit = existing.resolved_commit.clone(); + if record.creation_ref.is_none() { + record.creation_ref = existing.creation_ref.clone(); } } @@ -6469,10 +6475,11 @@ fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirecto WorkingDirectorySummary { working_directory_id: record.workdir_id.clone(), repository_id: record.repository_id.clone(), - requested_selector: record.selector.clone(), + creation_selector: record.creation_selector.clone(), + creation_ref: record.creation_ref.clone(), + current_selector: record.current_selector.clone(), + current_ref: record.current_ref.clone(), materializer_kind: MaterializerKind::LocalGitWorktree, - resolved_commit: record.resolved_commit.clone(), - resolved_tree: None, cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget { kind: "local_git_worktree".to_string(), working_directory_id: record.workdir_id.clone(), @@ -7110,8 +7117,10 @@ mod tests { workdir_id: "0000019a00000000000".to_string(), runtime_id: "embedded".to_string(), repository_id: "repo".to_string(), - selector: Some("develop".to_string()), - resolved_commit: Some("abcdef".to_string()), + creation_selector: Some("develop".to_string()), + creation_ref: Some("abcdef".to_string()), + current_selector: None, + current_ref: Some("fedcba".to_string()), materialization_status: "missing".to_string(), cleanliness: "clean".to_string(), created_at: "1".to_string(), @@ -7135,6 +7144,13 @@ mod tests { working_directory.status, WorkingDirectoryStatusKind::NotFound ); + assert_eq!( + working_directory.creation_selector.as_deref(), + Some("develop") + ); + assert_eq!(working_directory.creation_ref.as_deref(), Some("abcdef")); + assert_eq!(working_directory.current_selector, None); + assert_eq!(working_directory.current_ref.as_deref(), Some("fedcba")); let occupied_by = working_directory.occupied_by.as_ref().unwrap(); assert_eq!(occupied_by.runtime_id, "embedded"); assert_eq!(occupied_by.runtime_worker_id, 1); @@ -7155,8 +7171,10 @@ mod tests { workdir_id: "managed".to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), repository_id: "repo".to_string(), - selector: None, - resolved_commit: None, + creation_selector: None, + creation_ref: None, + current_selector: None, + current_ref: None, materialization_status: "present".to_string(), cleanliness: "clean".to_string(), created_at: "1".to_string(), @@ -7169,8 +7187,10 @@ mod tests { workdir_id: "runtime-direct".to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), repository_id: "repo".to_string(), - selector: None, - resolved_commit: None, + creation_selector: None, + creation_ref: None, + current_selector: None, + current_ref: None, materialization_status: "present".to_string(), cleanliness: "unknown".to_string(), created_at: "1".to_string(), @@ -7240,8 +7260,10 @@ mod tests { workdir_id: "runtime-direct".to_string(), runtime_id: "embedded".to_string(), repository_id: "repo".to_string(), - selector: None, - resolved_commit: None, + creation_selector: None, + creation_ref: None, + current_selector: None, + current_ref: None, materialization_status: "present".to_string(), cleanliness: "unknown".to_string(), created_at: "1".to_string(), @@ -8236,8 +8258,10 @@ mod tests { workdir_id: workdir_id.to_string(), runtime_id: "runtime-test".to_string(), repository_id: "repo-test".to_string(), - selector: Some("HEAD".to_string()), - resolved_commit: None, + creation_selector: Some("HEAD".to_string()), + creation_ref: None, + current_selector: None, + current_ref: None, materialization_status: status.to_string(), cleanliness: cleanliness.to_string(), created_at: now.clone(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 65ae6711..39513eb8 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -87,6 +87,11 @@ const MIGRATIONS: &[Migration] = &[ name: "remove unused control-plane Ticket tables", apply: remove_unused_control_plane_ticket_tables, }, + Migration { + version: 15, + name: "separate workdir creation evidence from current revision observation", + apply: add_workdir_revision_observations, + }, ]; struct Migration { @@ -238,8 +243,10 @@ pub struct WorkdirRegistryRecord { pub workdir_id: String, pub runtime_id: String, pub repository_id: String, - pub selector: Option, - pub resolved_commit: Option, + pub creation_selector: Option, + pub creation_ref: Option, + pub current_selector: Option, + pub current_ref: Option, pub materialization_status: String, pub cleanliness: String, pub created_at: String, @@ -1570,14 +1577,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, + workspace_id, workdir_id, runtime_id, repository_id, + creation_selector, creation_ref, current_selector, current_ref, materialization_status, cleanliness, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET runtime_id = excluded.runtime_id, repository_id = excluded.repository_id, - selector = excluded.selector, - resolved_commit = excluded.resolved_commit, + creation_selector = excluded.creation_selector, + creation_ref = excluded.creation_ref, + current_selector = excluded.current_selector, + current_ref = excluded.current_ref, materialization_status = excluded.materialization_status, cleanliness = excluded.cleanliness, updated_at = excluded.updated_at"#, @@ -1586,8 +1596,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.workdir_id, record.runtime_id, record.repository_id, - record.selector, - record.resolved_commit, + record.creation_selector, + record.creation_ref, + record.current_selector, + record.current_ref, record.materialization_status, record.cleanliness, record.created_at, @@ -1998,7 +2010,8 @@ fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result String { format!( - "SELECT workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, \ + "SELECT workspace_id, workdir_id, runtime_id, repository_id, \ + creation_selector, creation_ref, current_selector, current_ref, \ materialization_status, cleanliness, created_at, updated_at \ FROM workdir_registry {where_clause}" ) @@ -2012,12 +2025,14 @@ fn read_workdir_registry_record( workdir_id: row.get(1)?, runtime_id: row.get(2)?, repository_id: row.get(3)?, - selector: row.get(4)?, - resolved_commit: row.get(5)?, - materialization_status: row.get(6)?, - cleanliness: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, + creation_selector: row.get(4)?, + creation_ref: row.get(5)?, + current_selector: row.get(6)?, + current_ref: row.get(7)?, + materialization_status: row.get(8)?, + cleanliness: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) } @@ -2175,6 +2190,18 @@ DROP TABLE IF EXISTS tickets; Ok(()) } +fn add_workdir_revision_observations(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +ALTER TABLE workdir_registry RENAME COLUMN selector TO creation_selector; +ALTER TABLE workdir_registry RENAME COLUMN resolved_commit TO creation_ref; +ALTER TABLE workdir_registry ADD COLUMN current_selector TEXT; +ALTER TABLE workdir_registry ADD COLUMN current_ref TEXT; +"#, + )?; + Ok(()) +} + fn create_objective_event_tables(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -2857,7 +2884,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 14); + assert_eq!(store.schema_version().await.unwrap(), 15); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -2870,7 +2897,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 14); + assert_eq!(reopened.schema_version().await.unwrap(), 15); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -2982,6 +3009,24 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); "updated_at", ], ); + assert_columns( + &conn, + "workdir_registry", + [ + "workspace_id", + "workdir_id", + "runtime_id", + "repository_id", + "creation_selector", + "creation_ref", + "materialization_status", + "cleanliness", + "created_at", + "updated_at", + "current_selector", + "current_ref", + ], + ); assert_columns( &conn, "artifacts", @@ -3058,7 +3103,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 14); + assert_eq!(store.schema_version().await.unwrap(), 15); store .with_conn(|conn| { @@ -3158,10 +3203,66 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); ); } + #[test] + fn workdir_revision_migration_preserves_creation_evidence_and_leaves_observation_unknown() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" +CREATE TABLE workdir_registry ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + selector TEXT, + resolved_commit TEXT, + materialization_status TEXT NOT NULL, + cleanliness TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id) +); +INSERT INTO workdir_registry ( + workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, + materialization_status, cleanliness, created_at, updated_at +) VALUES ( + 'workspace', 'workdir', 'runtime', 'repository', 'develop', 'abcdef', + 'present', 'clean', '1', '2' +); +"#, + ) + .unwrap(); + + add_workdir_revision_observations(&conn).unwrap(); + + let values = conn + .query_row( + "SELECT creation_selector, creation_ref, current_selector, current_ref FROM workdir_registry", + [], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .unwrap(); + assert_eq!( + values, + ( + Some("develop".to_string()), + Some("abcdef".to_string()), + None, + None, + ) + ); + } + #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 14); + assert_eq!(store.schema_version().await.unwrap(), 15); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3199,7 +3300,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 14); + assert_eq!(store.schema_version().await.unwrap(), 15); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3313,8 +3414,10 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); workdir_id: "0000019a00000000001".to_string(), runtime_id: "embedded".to_string(), repository_id: "repo".to_string(), - selector: Some("develop".to_string()), - resolved_commit: Some("abcdef".to_string()), + creation_selector: Some("develop".to_string()), + creation_ref: Some("abcdef".to_string()), + current_selector: None, + current_ref: Some("abcdef".to_string()), materialization_status: "not_found".to_string(), cleanliness: "clean".to_string(), created_at: "2".to_string(), @@ -3326,8 +3429,10 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); workdir_id: "runtime-direct".to_string(), runtime_id: "embedded".to_string(), repository_id: "repo".to_string(), - selector: Some("feature".to_string()), - resolved_commit: Some("123456".to_string()), + creation_selector: Some("feature".to_string()), + creation_ref: Some("123456".to_string()), + current_selector: Some("feature".to_string()), + current_ref: Some("123456".to_string()), materialization_status: "present".to_string(), cleanliness: "unknown".to_string(), created_at: "3".to_string(), @@ -3373,7 +3478,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 14); + assert_eq!(store.schema_version().await.unwrap(), 15); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/docs/report/2026-07-30-spawn-worker-runtime-cli-mismatch.md b/docs/report/2026-07-30-spawn-worker-runtime-cli-mismatch.md new file mode 100644 index 00000000..7ba427c1 --- /dev/null +++ b/docs/report/2026-07-30-spawn-worker-runtime-cli-mismatch.md @@ -0,0 +1,28 @@ +# SpawnWorker が current `yoi-runtime` CLI と不整合で起動できない + +Date: 2026-07-30 + +## 発生状況 + +Workdir selector/ref変更の未コミット差分をreviewer Workerへ委譲するため、`SpawnWorker`をread-only scopeで呼び出した。 + +## 結果 + +Worker socketが作成されず、child stderrには以下が記録された。 + +```text +yoi-runtime: unexpected positional argument `worker` + +Usage: yoi-runtime [OPTIONS] +``` + +現在の`yoi-runtime`はRuntime REST serverの直接起動CLIであり、`worker` positional subcommandを受け付けない。SpawnWorker側のlauncherが旧CLI契約を使っている可能性がある。 + +## 影響 + +- reviewer/coder Workerをspawnできず、今回の差分は親Worker内で実装・検証した。 +- scope delegationやreviewer profile以前にprocess起動で失敗するため、Worker orchestration機能が利用できない。 + +## 改善案 + +SpawnWorker launcherがcurrent Worker/Runtime起動contractを使用しているかを確認し、CLI rename後のdirect executable契約と同期する。失敗時には実際に組み立てたargvと解決したexecutable pathもbounded diagnosticとして返すと、installed binary/worktree binaryの取り違えを判別しやすい。 diff --git a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts b/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts new file mode 100644 index 00000000..75e9602a --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts @@ -0,0 +1,61 @@ +import type { WorkingDirectorySummary } from "../sidebar/types.ts"; +import { formatCurrentWorkdirRevision } from "./workdir-revision.ts"; + +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +function assertEquals(actual: T, expected: T): void { + if (actual !== expected) { + throw new Error(`Expected ${String(expected)}, got ${String(actual)}`); + } +} + +function workdir( + current_selector: string | null, + current_ref: string | null, +): WorkingDirectorySummary { + return { + working_directory_id: "workdir-1", + repository_id: "repository-1", + current_selector, + current_ref, + materializer_kind: "local_git_worktree", + status: "active", + cleanup_target: { + kind: "local_git_worktree", + working_directory_id: "workdir-1", + repository_id: "repository-1", + }, + }; +} + +Deno.test("Git detached Workdir shows only its current ref", () => { + assertEquals( + formatCurrentWorkdirRevision( + workdir(null, "0123456789abcdef0123456789abcdef01234567"), + "git", + ), + "0123456789ab", + ); +}); + +Deno.test("Git Workdir with a selector shows selector at current ref", () => { + assertEquals( + formatCurrentWorkdirRevision( + workdir("feature/current", "fedcba9876543210fedcba9876543210fedcba98"), + "git", + ), + "feature/current@fedcba987654", + ); +}); + +Deno.test("non-Git Workdir does not receive Git hash formatting", () => { + assertEquals( + formatCurrentWorkdirRevision( + workdir("snapshot", "revision-value"), + "archive", + ), + "snapshot · revision-value", + ); +}); diff --git a/web/workspace/src/lib/workspace/settings/workdir-revision.ts b/web/workspace/src/lib/workspace/settings/workdir-revision.ts new file mode 100644 index 00000000..8c4a43e9 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/workdir-revision.ts @@ -0,0 +1,22 @@ +import type { WorkingDirectorySummary } from "../sidebar/types.ts"; + +export function formatCurrentWorkdirRevision( + workdir: WorkingDirectorySummary, + repositoryProvider: string | null | undefined, +): string { + const selector = workdir.current_selector?.trim() || null; + const reference = workdir.current_ref?.trim() || null; + + if (repositoryProvider?.toLowerCase() === "git") { + const hash = reference ? shortGitHash(reference) : null; + if (selector && hash) return `${selector}@${hash}`; + return selector ?? hash ?? "—"; + } + + if (selector && reference) return `${selector} · ${reference}`; + return selector ?? reference ?? "—"; +} + +function shortGitHash(reference: string): string { + return reference.length > 12 ? reference.slice(0, 12) : reference; +} diff --git a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte index 0d4f0fd2..8e5b126c 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte @@ -110,7 +110,7 @@ worker {worker.worker_id} · {worker.profile ? `${worker.profile} · ` : ''}{worker.state} · 🖥 {worker.host_id} - {worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''} + {worker.working_directory?.current_ref ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.current_ref.slice(0, 8)}` : ''} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 57b12028..0779f88f 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -128,10 +128,11 @@ export type WorkingDirectoryOccupancy = { export type WorkingDirectorySummary = { working_directory_id: string; repository_id: string; - requested_selector?: string | null; + creation_selector?: string | null; + creation_ref?: string | null; + current_selector?: string | null; + current_ref?: string | null; materializer_kind: string; - resolved_commit: string; - resolved_tree?: string | null; status: string; cleanliness?: string | null; primary_worker_id?: number | null; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 62126df8..baaa8b88 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -50,9 +50,11 @@ const options: WorkerLaunchOptionsResponse = { { working_directory_id: "wd-1-repo", repository_id: "repo", - requested_selector: "HEAD", + creation_selector: "HEAD", + creation_ref: "0123456789abcdef", + current_selector: null, + current_ref: "0123456789abcdef", materializer_kind: "local_git_worktree", - resolved_commit: "0123456789abcdef", status: "active", cleanliness: "clean", primary_worker_id: null, @@ -159,7 +161,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => ...options.working_directories[0], working_directory_id: "ticket-workdir", repository_id: "ticket-repo", - requested_selector: "work/ticket", + creation_selector: "work/ticket", }, ], }, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index ab77385a..f1f50d13 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -55,7 +55,8 @@ export function defaultWorkerLaunchForm( Boolean(current.working_directory_repository_id) && directory.repository_id === current.working_directory_repository_id && (!current.working_directory_selector || - directory.requested_selector === current.working_directory_selector) + (directory.current_selector ?? directory.creation_selector) === + current.working_directory_selector) ) ?? availableWorkingDirectories.find((directory) => Boolean(current.working_directory_repository_id) && directory.repository_id === current.working_directory_repository_id diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index e1830297..3e27539a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -1,6 +1,7 @@ diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 7664e7e7..bff608aa 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -2,12 +2,14 @@ import { goto } from '$app/navigation'; import { untrack } from 'svelte'; import { workspaceApiPath } from '$lib/workspace/api/http'; + import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch'; import type { BrowserCreateWorkerResponse, BrowserWorkingDirectoryCreateResponse, Diagnostic, WorkerLaunchOptionsResponse, + WorkingDirectorySummary, } from '$lib/workspace/sidebar/types'; import type { PageProps } from './$types'; @@ -22,6 +24,12 @@ diagnostics?: Diagnostic[]; }; + function workdirOptionLabel(directory: WorkingDirectorySummary): string { + const provider = data.repositories?.items.find((repository) => repository.id === directory.repository_id) + ?.provider; + return `${directory.repository_id} · ${formatCurrentWorkdirRevision(directory, provider)}`; + } + let { data }: PageProps = $props(); let workspaceId = $derived(data.workspaceId); const ticketContext = untrack(() => data.ticketContext); @@ -296,7 +304,7 @@ {#each availableWorkingDirectories as directory} {/each}