workdir: report current selector and ref

This commit is contained in:
2026-07-31 23:48:46 +09:00
parent 32d3f8f75e
commit 06a98fe30c
15 changed files with 395 additions and 86 deletions
+7 -5
View File
@@ -119,13 +119,15 @@ pub struct BackendWorkingDirectorySummary {
pub working_directory_id: String,
pub repository_id: String,
#[serde(default)]
pub requested_selector: Option<String>,
pub creation_selector: Option<String>,
#[serde(default)]
pub creation_ref: Option<String>,
#[serde(default)]
pub current_selector: Option<String>,
#[serde(default)]
pub current_ref: Option<String>,
pub materializer_kind: String,
#[serde(default)]
pub resolved_commit: Option<String>,
#[serde(default)]
pub resolved_tree: Option<String>,
#[serde(default)]
pub cleanup_target: Option<BackendWorkingDirectoryCleanupTarget>,
pub status: String,
#[serde(default)]
+11 -5
View File
@@ -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<String>,
pub creation_selector: Option<String>,
/// Provider-specific immutable ref resolved when this Workdir was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>,
/// Selector currently observed from the materialized Workdir, when one exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_selector: Option<String>,
/// Provider-specific immutable ref currently observed from the materialized Workdir.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>,
pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
+56 -8
View File
@@ -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<String>, Option<String>) {
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();
+47 -23
View File
@@ -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(),
+130 -25
View File
@@ -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<String>,
pub resolved_commit: Option<String>,
pub creation_selector: Option<String>,
pub creation_ref: Option<String>,
pub current_selector: Option<String>,
pub current_ref: Option<String>,
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<Work
fn workdir_registry_select_sql(where_clause: &str) -> 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<String>>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(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(),