worktree-runtime 0.1.0a4__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/Cargo.lock +2 -2
  2. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/Cargo.toml +2 -2
  3. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/PKG-INFO +2 -2
  4. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/AGENTS.md +12 -7
  5. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/application/binding_lifecycle.rs +22 -11
  6. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain/audit.rs +12 -8
  7. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain/binding.rs +11 -2
  8. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain/paths.rs +9 -14
  9. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/audit_dir.rs +6 -5
  10. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/audit_reader.rs +4 -2
  11. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/audit_writer.rs +12 -2
  12. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/binding_store.rs +21 -6
  13. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/git_adapter.rs +0 -9
  14. worktree_runtime-0.1.1/crates/worktree_runtime_core/src/infrastructure/project_id_resolver.rs +177 -0
  15. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/worktree_base_resolver.rs +112 -93
  16. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/worktree_config_loader.rs +35 -15
  17. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure.rs +0 -1
  18. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/common/mod.rs +1 -1
  19. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/e2e/binding_lifecycle_tests.rs +10 -3
  20. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/audit_dir_tests.rs +2 -2
  21. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/audit_writer_tests.rs +8 -6
  22. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/binding_store_tests.rs +12 -12
  23. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/main.rs +0 -1
  24. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/project_id_resolver_tests.rs +42 -30
  25. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/worktree_base_resolver_tests.rs +27 -15
  26. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/worktree_config_loader_tests.rs +22 -14
  27. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/audit_tests.rs +26 -4
  28. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/Cargo.toml +6 -0
  29. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/src/convert.rs +19 -4
  30. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/src/functions.rs +87 -42
  31. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/src/types.rs +19 -7
  32. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/tests/pyo3_signature_freeze_tests.rs +2 -2
  33. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/pyproject.toml +3 -3
  34. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/api/__init__.pyi +8 -8
  35. worktree_runtime-0.1.0a4/crates/worktree_runtime_core/src/infrastructure/path_resolution.rs +0 -8
  36. worktree_runtime-0.1.0a4/crates/worktree_runtime_core/src/infrastructure/project_id_resolver.rs +0 -133
  37. worktree_runtime-0.1.0a4/crates/worktree_runtime_core/tests/integration/path_resolution_tests.rs +0 -17
  38. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/LICENSE +0 -0
  39. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/README.md +0 -0
  40. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/Cargo.toml +0 -0
  41. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/application/git_worktree_service.rs +0 -0
  42. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/application/validation.rs +0 -0
  43. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/application.rs +0 -0
  44. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain/git_worktree.rs +0 -0
  45. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain/github_policy.rs +0 -0
  46. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain/porcelain.rs +0 -0
  47. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/domain.rs +0 -0
  48. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/audit_file_io.rs +0 -0
  49. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/env_keys.rs +0 -0
  50. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/github_policy_loader.rs +0 -0
  51. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/infrastructure/worktree_list_reader.rs +0 -0
  52. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/src/lib.rs +0 -0
  53. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/e2e/git_worktree_service_tests.rs +0 -0
  54. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/e2e/main.rs +0 -0
  55. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/e2e/validation_tests.rs +0 -0
  56. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/audit_file_io_tests.rs +0 -0
  57. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/audit_reader_tests.rs +0 -0
  58. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/git_adapter_tests.rs +0 -0
  59. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/github_policy_loader_tests.rs +0 -0
  60. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/integration/worktree_list_reader_tests.rs +0 -0
  61. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/binding_tests.rs +0 -0
  62. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/env_keys_tests.rs +0 -0
  63. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/git_worktree_tests.rs +0 -0
  64. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/github_policy_tests.rs +0 -0
  65. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/main.rs +0 -0
  66. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/paths_tests.rs +0 -0
  67. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_core/tests/unit/porcelain_tests.rs +0 -0
  68. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/AGENTS.md +0 -0
  69. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/crates/worktree_runtime_pyo3/src/lib.rs +0 -0
  70. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/_worktree_runtime_rust/__init__.py +0 -0
  71. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/AGENTS.md +0 -0
  72. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/SURFACE.yaml +0 -0
  73. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/__init__.py +0 -0
  74. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/api/__init__.py +0 -0
  75. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/presentation/__init__.py +0 -0
  76. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/presentation/audit_cli.py +0 -0
  77. {worktree_runtime-0.1.0a4 → worktree_runtime-0.1.1}/src/worktree_runtime/py.typed +0 -0
@@ -743,7 +743,7 @@ dependencies = [
743
743
 
744
744
  [[package]]
745
745
  name = "worktree_runtime_core"
746
- version = "0.1.0-alpha.4"
746
+ version = "0.1.1"
747
747
  dependencies = [
748
748
  "chrono",
749
749
  "fs2",
@@ -760,7 +760,7 @@ dependencies = [
760
760
 
761
761
  [[package]]
762
762
  name = "worktree_runtime_pyo3"
763
- version = "0.1.0-alpha.4"
763
+ version = "0.1.1"
764
764
  dependencies = [
765
765
  "chrono",
766
766
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/worktree_runtime_core", "crates/worktree_runtime_pyo3"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.0-alpha.4"
6
+ version = "0.1.1"
7
7
  edition = "2021"
8
8
  rust-version = "1.85"
9
9
  license = "LicenseRef-Proprietary"
@@ -18,7 +18,7 @@ sha1 = "0.10"
18
18
  fs2 = "0.4"
19
19
  tempfile = "3.10"
20
20
  wait-timeout = "0.2"
21
- pyo3 = { version = "0.29", features = ["extension-module", "abi3-py310"] }
21
+ pyo3 = { version = "0.29", features = ["abi3-py310"] }
22
22
  chrono = "0.4"
23
23
 
24
24
  [profile.release]
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: worktree-runtime
3
- Version: 0.1.0a4
4
- Classifier: Development Status :: 3 - Alpha
3
+ Version: 0.1.1
4
+ Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: License :: Other/Proprietary License
6
6
  Classifier: Operating System :: MacOS
7
7
  Classifier: Operating System :: Microsoft :: Windows
@@ -38,13 +38,18 @@ do not treat pytest as the SSOT.
38
38
  - **Config precedence**: `NOVELER_WORKTREE_BASE` and
39
39
  `config/worktree.yaml` overrides are honored only when `CI` or
40
40
  `GITHUB_ACTIONS` is set (`worktree_base_resolver.rs`). Outside CI the
41
- base is `~/.adaptive-task/worktrees/<project_id>`. Parse failures in
42
- `pyproject.toml` / `config/worktree.yaml` log a warning and fall back to
43
- defaults; they are not distinguishable from "file absent" to callers.
44
- - **project_id**: `resolve_project_id` returns the declared value unchecked;
45
- `resolve_project_id_checked` rejects invalid ids; every function that
46
- turns an id into a directory name (`resolve_state_dir_name`) validates it
47
- and falls back to `noveler`.
41
+ base is `~/.adaptive-task/worktrees/<project_id>`. A missing
42
+ `config/worktree.yaml` is the default path. A file that exists but cannot
43
+ be read or parsed raises `WORKTREE_CONFIG_UNREADABLE` from the resolvers
44
+ that load it (`resolve_worktree_base`, `resolve_worktree_dir`,
45
+ `resolve_known_worktree_bases`) when CI consults it.
46
+ - **project_id**: a missing `pyproject.toml` or a missing
47
+ `tool.adaptive-task.project_id` is `noveler`. An existing file that is not
48
+ valid TOML raises `WORKTREE_PROJECT_CONFIG_UNREADABLE`. A declared id that
49
+ fails `^[a-z][a-z0-9_-]{0,63}$` raises `ProjectIdInvalidError`
50
+ (`PROJECT_ID_INVALID`) from `resolve_project_id` and
51
+ `resolve_state_dir_name`; it is not used as a directory name. An empty id
52
+ stays `noveler`.
48
53
  - **Logging**: `log::debug!` / `log::warn!` for diagnostics; the two
49
54
  `eprintln!` breadcrumbs (`failopen_message`, `suspicious_extra_message`)
50
55
  are frozen by tests and stay on stderr.
@@ -57,11 +57,13 @@ impl From<BindingStoreError> for RegisterBindingError {
57
57
  }
58
58
  }
59
59
 
60
- fn session_state_subpath(repo_root: &Path) -> PathBuf {
61
- let pid = resolve_project_id(Some(&repo_root.to_string_lossy()));
62
- PathBuf::from(resolve_state_dir_name(Some(&pid)))
60
+ fn session_state_subpath(
61
+ repo_root: &Path,
62
+ ) -> Result<PathBuf, crate::infrastructure::project_id_resolver::ProjectResolveError> {
63
+ let pid = resolve_project_id(Some(&repo_root.to_string_lossy()))?;
64
+ Ok(PathBuf::from(resolve_state_dir_name(Some(&pid))?)
63
65
  .join("state")
64
- .join("adaptive-task")
66
+ .join("adaptive-task"))
65
67
  }
66
68
 
67
69
  pub fn register_binding(
@@ -95,7 +97,16 @@ pub fn register_binding(
95
97
  let state_path = if let Some(sp) = state_path {
96
98
  normalize_path(sp)
97
99
  } else {
98
- let subpath = session_state_subpath(&repo);
100
+ let subpath = session_state_subpath(&repo).map_err(|err| match err {
101
+ crate::infrastructure::project_id_resolver::ProjectResolveError::Invalid {
102
+ message,
103
+ reason_code,
104
+ } => RegisterBindingError::Validation {
105
+ reason_code,
106
+ message,
107
+ },
108
+ other => RegisterBindingError::Store(other.into()),
109
+ })?;
99
110
  normalize_path(&PathBuf::from(&worktree).join(&subpath).to_string_lossy())
100
111
  };
101
112
  let binding = WorktreeBinding::new(WorktreeBindingFields {
@@ -117,7 +128,7 @@ pub fn register_binding(
117
128
  message: validation.message,
118
129
  });
119
130
  }
120
- let store = WorktreeBindingStore::new(&repo);
131
+ let store = WorktreeBindingStore::new(&repo)?;
121
132
  store.upsert(&binding)?;
122
133
  Ok(binding)
123
134
  }
@@ -126,18 +137,18 @@ pub fn load_binding(
126
137
  session_id: &str,
127
138
  repo_root: &str,
128
139
  ) -> Result<Option<WorktreeBinding>, BindingStoreError> {
129
- WorktreeBindingStore::new(repo_root).load(session_id)
140
+ WorktreeBindingStore::new(repo_root)?.load(session_id)
130
141
  }
131
142
 
132
143
  pub fn list_bindings(repo_root: &str) -> Result<Vec<WorktreeBinding>, BindingStoreError> {
133
- WorktreeBindingStore::new(repo_root).list()
144
+ WorktreeBindingStore::new(repo_root)?.list()
134
145
  }
135
146
 
136
147
  pub fn remove_binding(
137
148
  session_id: &str,
138
149
  repo_root: &str,
139
150
  ) -> Result<Option<WorktreeBinding>, BindingStoreError> {
140
- WorktreeBindingStore::new(repo_root).remove(session_id)
151
+ WorktreeBindingStore::new(repo_root)?.remove(session_id)
141
152
  }
142
153
 
143
154
  /// Rebuild a binding from legacy `metadata.json` and persist it. `Ok(None)`
@@ -164,7 +175,7 @@ fn recover_binding_from_metadata(
164
175
  metadata_root: &Path,
165
176
  repo_root: &Path,
166
177
  ) -> Result<Option<WorktreeBinding>, BindingStoreError> {
167
- let state_subpath = session_state_subpath(repo_root);
178
+ let state_subpath = session_state_subpath(repo_root)?;
168
179
  let metadata = metadata_root
169
180
  .join(state_subpath)
170
181
  .join("sessions")
@@ -310,7 +321,7 @@ fn build_env_fallback_binding(
310
321
  .map(|s| s.to_string())
311
322
  .unwrap_or_default();
312
323
  let state_path = PathBuf::from(&normalized_worktree)
313
- .join(session_state_subpath(&repo))
324
+ .join(session_state_subpath(&repo).ok()?)
314
325
  .to_string_lossy()
315
326
  .to_string();
316
327
  Some(WorktreeBinding::new(WorktreeBindingFields {
@@ -1,9 +1,9 @@
1
1
  //! Audit record value objects, schema constants, and pure assembly/parse.
2
- use serde::{Deserialize, Serialize};
2
+ use serde::Serialize;
3
3
  use serde_json::Value;
4
4
 
5
5
  pub const RECORD_CAP_BYTES: usize = 4096;
6
- pub const SCHEMA_VERSION: &str = "v1";
6
+ pub const SCHEMA_VERSION: &str = "v2";
7
7
  pub const VALID_KINDS: &[&str] = &["worktree_remove", "branch_delete"];
8
8
  pub const VALID_INVOKERS: &[&str] = &[
9
9
  "canonical_wrapper",
@@ -23,7 +23,7 @@ pub fn is_secret_pattern(text: &str) -> bool {
23
23
  || lower.contains("password")
24
24
  }
25
25
 
26
- #[derive(Debug, Clone, Serialize, Deserialize)]
26
+ #[derive(Debug, Clone, Serialize)]
27
27
  pub struct AuditRecord {
28
28
  pub schema_version: String,
29
29
  pub timestamp: String,
@@ -35,7 +35,7 @@ pub struct AuditRecord {
35
35
  pub host: String,
36
36
  pub invoker: String,
37
37
  pub branch: String,
38
- pub worktree_path: String,
38
+ pub worktree: String,
39
39
  pub outcome: String,
40
40
  pub elapsed_ms: Option<i64>,
41
41
  pub correlation_id: Option<String>,
@@ -50,7 +50,7 @@ pub struct AuditRecord {
50
50
  pub struct AuditEventInput<'a> {
51
51
  pub kind: &'a str,
52
52
  pub branch: &'a str,
53
- pub worktree_path: &'a str,
53
+ pub worktree: &'a str,
54
54
  pub invoker: &'a str,
55
55
  pub outcome: &'a str,
56
56
  pub session_id: Option<&'a str>,
@@ -78,7 +78,7 @@ pub fn assemble_record(input: AuditEventInput<'_>, runtime: AuditRuntimeFields<'
78
78
  "schema_version": SCHEMA_VERSION, "timestamp": runtime.timestamp, "kind": input.kind,
79
79
  "session_id": input.session_id, "pid": runtime.pid, "parent_pid": runtime.parent_pid,
80
80
  "cwd": runtime.cwd, "host": runtime.host, "invoker": input.invoker, "branch": input.branch,
81
- "worktree_path": input.worktree_path, "outcome": input.outcome,
81
+ "worktree": input.worktree, "outcome": input.outcome,
82
82
  "elapsed_ms": input.elapsed_ms, "correlation_id": input.correlation_id,
83
83
  "truncated": false, "extra": extra_value,
84
84
  })
@@ -128,7 +128,11 @@ pub fn parse_record(payload: &Value) -> Option<AuditRecord> {
128
128
  let kind = obj.get("kind")?.as_str()?.to_string();
129
129
  let invoker = obj.get("invoker")?.as_str()?.to_string();
130
130
  let branch = obj.get("branch")?.as_str()?.to_string();
131
- let worktree_path = obj.get("worktree_path")?.as_str()?.to_string();
131
+ let worktree = obj
132
+ .get("worktree")
133
+ .or_else(|| obj.get("worktree_path"))?
134
+ .as_str()?
135
+ .to_string();
132
136
  let outcome = obj.get("outcome")?.as_str()?.to_string();
133
137
  let pid = obj.get("pid")?.as_i64()?;
134
138
  let schema_version = obj
@@ -172,7 +176,7 @@ pub fn parse_record(payload: &Value) -> Option<AuditRecord> {
172
176
  host,
173
177
  invoker,
174
178
  branch,
175
- worktree_path,
179
+ worktree,
176
180
  outcome,
177
181
  elapsed_ms,
178
182
  correlation_id,
@@ -1,11 +1,17 @@
1
1
  //! Worktree binding records and validation primitives (product vocabulary).
2
2
  use super::paths::normalize_path;
3
- use serde::{Deserialize, Serialize};
4
3
  use serde_json::Value;
5
4
 
6
5
  pub const CURRENT_SCHEMA_VERSION: i64 = 2;
7
6
 
8
- #[derive(Debug, Clone, Serialize, Deserialize)]
7
+ /// A session ↔ worktree record.
8
+ ///
9
+ /// `schema_version` is the version of the payload this value was *read
10
+ /// from* (`from_dict` accepts v1 keys). It is informational: the struct is
11
+ /// always held in v2 vocabulary and [`WorktreeBinding::to_dict`] always
12
+ /// writes [`CURRENT_SCHEMA_VERSION`], so persisting a v1 record upgrades it
13
+ /// on disk. Do not write `self.schema_version` back out.
14
+ #[derive(Debug, Clone)]
9
15
  pub struct WorktreeBinding {
10
16
  pub session_id: String,
11
17
  pub worktree: String,
@@ -14,6 +20,7 @@ pub struct WorktreeBinding {
14
20
  pub git_common_dir: String,
15
21
  pub main_worktree: String,
16
22
  pub commit_ish: String,
23
+ /// Schema version of the source payload (see the type docs).
17
24
  pub schema_version: i64,
18
25
  }
19
26
 
@@ -75,6 +82,8 @@ impl WorktreeBinding {
75
82
  }))
76
83
  }
77
84
 
85
+ /// Serialize in the current (v2) vocabulary; always writes
86
+ /// [`CURRENT_SCHEMA_VERSION`], whatever `self.schema_version` says.
78
87
  pub fn to_dict(&self) -> Value {
79
88
  serde_json::json!({
80
89
  "session_id": self.session_id,
@@ -100,23 +100,18 @@ pub fn home_dir(home: Option<&str>) -> PathBuf {
100
100
  if let Some(h) = home {
101
101
  return expanduser(h);
102
102
  }
103
- if cfg!(windows) {
104
- if let Ok(userprofile) = env::var("USERPROFILE") {
105
- if !userprofile.is_empty() {
106
- return expanduser(&userprofile);
103
+ let keys: &[&str] = if cfg!(windows) {
104
+ &["USERPROFILE", "HOME"]
105
+ } else {
106
+ &["HOME", "USERPROFILE"]
107
+ };
108
+ for key in keys {
109
+ if let Ok(value) = env::var(key) {
110
+ if !value.is_empty() {
111
+ return expanduser(&value);
107
112
  }
108
113
  }
109
114
  }
110
- if let Ok(home_env) = env::var("HOME") {
111
- if !home_env.is_empty() {
112
- return expanduser(&home_env);
113
- }
114
- }
115
- if let Ok(userprofile) = env::var("USERPROFILE") {
116
- if !userprofile.is_empty() {
117
- return expanduser(&userprofile);
118
- }
119
- }
120
115
  PathBuf::from(".")
121
116
  }
122
117
 
@@ -6,7 +6,8 @@ use crate::infrastructure::env_keys::{
6
6
  use crate::infrastructure::project_id_resolver::{resolve_project_id, resolve_state_dir_name};
7
7
  use std::path::PathBuf;
8
8
 
9
- pub fn resolve_adaptive_task_shared_audit_dir() -> PathBuf {
9
+ pub fn resolve_adaptive_task_shared_audit_dir(
10
+ ) -> Result<PathBuf, crate::infrastructure::project_id_resolver::ProjectResolveError> {
10
11
  let shared_env = std::env::var(ADAPTIVE_TASK_SHARED_DIR_ENV)
11
12
  .ok()
12
13
  .map(|v| v.trim().to_string())
@@ -20,7 +21,7 @@ pub fn resolve_adaptive_task_shared_audit_dir() -> PathBuf {
20
21
  .unwrap_or_default();
21
22
  if !shared_env.is_empty() {
22
23
  let base = expanduser(&shared_env);
23
- return base.join("audit");
24
+ return Ok(base.join("audit"));
24
25
  }
25
26
  let project_root = std::env::var(NOVELER_PROJECT_ROOT_ENV)
26
27
  .unwrap_or_default()
@@ -32,7 +33,7 @@ pub fn resolve_adaptive_task_shared_audit_dir() -> PathBuf {
32
33
  std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
33
34
  };
34
35
  let root = std::fs::canonicalize(&base).unwrap_or(base);
35
- let state_dir_name =
36
- resolve_state_dir_name(Some(&resolve_project_id(Some(&root.to_string_lossy()))));
37
- root.join(state_dir_name).join("shared").join("audit")
36
+ let project_id = resolve_project_id(Some(&root.to_string_lossy()))?;
37
+ let state_dir_name = resolve_state_dir_name(Some(&project_id))?;
38
+ Ok(root.join(state_dir_name).join("shared").join("audit"))
38
39
  }
@@ -11,8 +11,10 @@ pub fn read_records(
11
11
  since: Option<chrono::DateTime<chrono::Utc>>,
12
12
  until: Option<chrono::DateTime<chrono::Utc>>,
13
13
  ) -> Vec<AuditRecord> {
14
- let audit_dir = resolve_adaptive_task_shared_audit_dir();
15
14
  let mut out: Vec<AuditRecord> = Vec::new();
15
+ let Ok(audit_dir) = resolve_adaptive_task_shared_audit_dir() else {
16
+ return out;
17
+ };
16
18
  // Rotated archives first (oldest first), then the current file, so the
17
19
  // history is complete even after rotation.
18
20
  let mut files = list_archives(&audit_dir);
@@ -57,7 +59,7 @@ fn read_file_into(
57
59
  }
58
60
  }
59
61
  if let Some(wp) = worktree_path {
60
- if record.worktree_path != wp {
62
+ if record.worktree != wp {
61
63
  continue;
62
64
  }
63
65
  }
@@ -40,7 +40,15 @@ pub fn suspicious_extra_message(keys: &[&str]) -> String {
40
40
  }
41
41
 
42
42
  pub fn record_event(input: AuditEventInput) -> Result<(), AuditWriteError> {
43
- let audit_dir = resolve_adaptive_task_shared_audit_dir();
43
+ let audit_dir = match resolve_adaptive_task_shared_audit_dir() {
44
+ Ok(dir) => dir,
45
+ Err(err) => {
46
+ return Err(AuditWriteError {
47
+ stage: "project_id",
48
+ message: err.to_string(),
49
+ });
50
+ }
51
+ };
44
52
  if let Err(exc) = std::fs::create_dir_all(&audit_dir) {
45
53
  return Err(fail("mkdir", exc.to_string()));
46
54
  }
@@ -99,7 +107,9 @@ pub fn record_event(input: AuditEventInput) -> Result<(), AuditWriteError> {
99
107
  }
100
108
 
101
109
  pub fn reap_rotation_sentinel() {
102
- let audit_dir = resolve_adaptive_task_shared_audit_dir();
110
+ let Ok(audit_dir) = resolve_adaptive_task_shared_audit_dir() else {
111
+ return;
112
+ };
103
113
  reap_stale_sentinel(&audit_dir.join(AUDIT_FILENAME));
104
114
  }
105
115
 
@@ -7,7 +7,9 @@
7
7
  use crate::domain::binding::{WorktreeBinding, CURRENT_SCHEMA_VERSION};
8
8
  use crate::domain::paths::normalize_path;
9
9
  use crate::infrastructure::git_adapter::git_output;
10
- use crate::infrastructure::project_id_resolver::{resolve_project_id, store_relative_path};
10
+ use crate::infrastructure::project_id_resolver::{
11
+ resolve_project_id, store_relative_path, ProjectResolveError,
12
+ };
11
13
  use fs2::FileExt;
12
14
  use serde_json::{json, Value};
13
15
  use std::fmt;
@@ -27,6 +29,8 @@ pub enum BindingStoreError {
27
29
  path: PathBuf,
28
30
  source: std::io::Error,
29
31
  },
32
+ /// The store path could not be built because project id resolution failed.
33
+ Project(ProjectResolveError),
30
34
  }
31
35
 
32
36
  impl BindingStoreError {
@@ -34,6 +38,10 @@ impl BindingStoreError {
34
38
  match self {
35
39
  BindingStoreError::Corrupt { .. } => "WORKTREE_BINDING_STORE_CORRUPT",
36
40
  BindingStoreError::Io { .. } => "WORKTREE_BINDING_STORE_IO",
41
+ BindingStoreError::Project(ProjectResolveError::Invalid { .. }) => "PROJECT_ID_INVALID",
42
+ BindingStoreError::Project(ProjectResolveError::Unreadable { .. }) => {
43
+ "WORKTREE_PROJECT_CONFIG_UNREADABLE"
44
+ }
37
45
  }
38
46
  }
39
47
  }
@@ -55,12 +63,19 @@ impl fmt::Display for BindingStoreError {
55
63
  path.display(),
56
64
  source
57
65
  ),
66
+ BindingStoreError::Project(err) => write!(f, "{err}"),
58
67
  }
59
68
  }
60
69
  }
61
70
 
62
71
  impl std::error::Error for BindingStoreError {}
63
72
 
73
+ impl From<ProjectResolveError> for BindingStoreError {
74
+ fn from(err: ProjectResolveError) -> Self {
75
+ BindingStoreError::Project(err)
76
+ }
77
+ }
78
+
64
79
  pub fn resolve_store_root(candidate: &Path) -> PathBuf {
65
80
  let git_common_dir = match git_output(
66
81
  candidate,
@@ -109,15 +124,15 @@ impl Drop for StoreLock {
109
124
  }
110
125
 
111
126
  impl WorktreeBindingStore {
112
- pub fn new(main_worktree: impl AsRef<Path>) -> Self {
127
+ pub fn new(main_worktree: impl AsRef<Path>) -> Result<Self, BindingStoreError> {
113
128
  let normalized = normalize_path(&main_worktree.as_ref().to_string_lossy());
114
129
  let resolved = resolve_store_root(Path::new(&normalized));
115
- let project_id = resolve_project_id(Some(&resolved.to_string_lossy()));
116
- let path = resolved.join(store_relative_path(Some(&project_id)));
117
- Self {
130
+ let project_id = resolve_project_id(Some(&resolved.to_string_lossy()))?;
131
+ let path = resolved.join(store_relative_path(Some(&project_id))?);
132
+ Ok(Self {
118
133
  main_worktree: resolved,
119
134
  path,
120
- }
135
+ })
121
136
  }
122
137
 
123
138
  pub fn lock_path(&self) -> PathBuf {
@@ -57,15 +57,6 @@ pub enum GitInvokeError {
57
57
  Exit { code: i32, stderr: String },
58
58
  }
59
59
 
60
- impl GitInvokeError {
61
- /// `true` for failures of the git installation itself rather than of the
62
- /// repository state (`Exit`). Callers use this to separate
63
- /// "git is unavailable" from "not a repository / detached HEAD".
64
- pub fn is_infrastructure(&self) -> bool {
65
- !matches!(self, GitInvokeError::Exit { .. })
66
- }
67
- }
68
-
69
60
  impl fmt::Display for GitInvokeError {
70
61
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71
62
  match self {
@@ -0,0 +1,177 @@
1
+ //! Project identifier resolution from pyproject.toml.
2
+ use crate::domain::paths::{normalize_path, validate_project_id, LEGACY_PROJECT_ID};
3
+ use crate::infrastructure::env_keys::NOVELER_REPO_ROOT_ENV;
4
+ use crate::infrastructure::git_adapter::git_repo_root;
5
+ use std::collections::HashMap;
6
+ use std::fmt;
7
+ use std::io::ErrorKind;
8
+ use std::path::{Path, PathBuf};
9
+ use std::sync::Mutex;
10
+
11
+ pub const PROJECT_CONFIG_UNREADABLE: &str = "WORKTREE_PROJECT_CONFIG_UNREADABLE";
12
+
13
+ /// A declared project id could not be used. Missing files and a missing
14
+ /// `project_id` key are not errors; they stay [`LEGACY_PROJECT_ID`].
15
+ #[derive(Debug, Clone)]
16
+ pub enum ProjectResolveError {
17
+ Invalid {
18
+ message: String,
19
+ reason_code: String,
20
+ },
21
+ Unreadable {
22
+ message: String,
23
+ },
24
+ }
25
+
26
+ impl ProjectResolveError {
27
+ pub fn reason_code(&self) -> &str {
28
+ match self {
29
+ ProjectResolveError::Invalid { reason_code, .. } => reason_code,
30
+ ProjectResolveError::Unreadable { .. } => PROJECT_CONFIG_UNREADABLE,
31
+ }
32
+ }
33
+
34
+ pub fn message(&self) -> &str {
35
+ match self {
36
+ ProjectResolveError::Invalid { message, .. }
37
+ | ProjectResolveError::Unreadable { message } => message,
38
+ }
39
+ }
40
+ }
41
+
42
+ impl fmt::Display for ProjectResolveError {
43
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44
+ write!(f, "{}: {}", self.reason_code(), self.message())
45
+ }
46
+ }
47
+
48
+ impl std::error::Error for ProjectResolveError {}
49
+
50
+ pub(crate) const PROJECT_ID_TABLE_KEY: &str = "adaptive-task";
51
+ const PROJECT_ID_FIELD: &str = "project_id";
52
+
53
+ fn read_pyproject_project_id(normalized_root: &str) -> Result<String, ProjectResolveError> {
54
+ if normalized_root.is_empty() {
55
+ return Ok(LEGACY_PROJECT_ID.to_string());
56
+ }
57
+ let pyproject = Path::new(normalized_root).join("pyproject.toml");
58
+ let content = match std::fs::read_to_string(&pyproject) {
59
+ Ok(c) => c,
60
+ Err(err) if err.kind() == ErrorKind::NotFound => return Ok(LEGACY_PROJECT_ID.to_string()),
61
+ Err(err) => {
62
+ return Err(ProjectResolveError::Unreadable {
63
+ message: format!(
64
+ "pyproject.toml at {} could not be read: {err}",
65
+ pyproject.display()
66
+ ),
67
+ });
68
+ }
69
+ };
70
+ let data: toml::Value = match content.parse() {
71
+ Ok(d) => d,
72
+ Err(err) => {
73
+ return Err(ProjectResolveError::Unreadable {
74
+ message: format!(
75
+ "pyproject.toml at {} could not be parsed: {err}",
76
+ pyproject.display()
77
+ ),
78
+ });
79
+ }
80
+ };
81
+ let Some(project_id) = data
82
+ .get("tool")
83
+ .and_then(|t| t.get(PROJECT_ID_TABLE_KEY))
84
+ .and_then(|a| a.get(PROJECT_ID_FIELD))
85
+ .and_then(|v| v.as_str())
86
+ else {
87
+ return Ok(LEGACY_PROJECT_ID.to_string());
88
+ };
89
+ if project_id.is_empty() {
90
+ return Ok(LEGACY_PROJECT_ID.to_string());
91
+ }
92
+ validate_project_id(project_id).map_err(|(message, reason_code)| ProjectResolveError::Invalid {
93
+ message,
94
+ reason_code,
95
+ })
96
+ }
97
+
98
+ static PROJECT_ID_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
99
+
100
+ fn cached_project_id(normalized_root: &str) -> Result<String, ProjectResolveError> {
101
+ let mut cache = PROJECT_ID_CACHE.lock().unwrap();
102
+ let cache = cache.get_or_insert_with(HashMap::new);
103
+ if let Some(val) = cache.get(normalized_root) {
104
+ return Ok(val.clone());
105
+ }
106
+ let val = read_pyproject_project_id(normalized_root)?;
107
+ cache.insert(normalized_root.to_string(), val.clone());
108
+ Ok(val)
109
+ }
110
+
111
+ pub fn clear_cache() {
112
+ let mut cache = PROJECT_ID_CACHE.lock().unwrap();
113
+ *cache = None;
114
+ }
115
+
116
+ pub(crate) fn resolve_root(repo_root: Option<&str>) -> String {
117
+ let root = repo_root
118
+ .filter(|s| !s.is_empty())
119
+ .map(|s| s.to_string())
120
+ .unwrap_or_else(|| std::env::var(NOVELER_REPO_ROOT_ENV).unwrap_or_default());
121
+ if root.is_empty() {
122
+ git_repo_root(None)
123
+ } else {
124
+ root
125
+ }
126
+ }
127
+
128
+ pub fn resolve_project_id(repo_root: Option<&str>) -> Result<String, ProjectResolveError> {
129
+ let root = resolve_root(repo_root);
130
+ if root.is_empty() {
131
+ return Ok(LEGACY_PROJECT_ID.to_string());
132
+ }
133
+ cached_project_id(&root)
134
+ }
135
+
136
+ /// Directory name for a project's state root. An id that fails
137
+ /// `^[a-z][a-z0-9_-]{0,63}$` is [`ProjectResolveError::Invalid`] and is never
138
+ /// turned into a path. An empty id stays `.{LEGACY_PROJECT_ID}`.
139
+ pub fn resolve_state_dir_name(project_id: Option<&str>) -> Result<String, ProjectResolveError> {
140
+ let raw = match project_id {
141
+ Some(pid) if !pid.is_empty() => pid.to_string(),
142
+ Some(_) => return Ok(format!(".{LEGACY_PROJECT_ID}")),
143
+ None => resolve_project_id(None)?,
144
+ };
145
+ let pid = validate_project_id(&raw).map_err(|(message, reason_code)| {
146
+ ProjectResolveError::Invalid {
147
+ message,
148
+ reason_code,
149
+ }
150
+ })?;
151
+ Ok(format!(".{pid}"))
152
+ }
153
+
154
+ pub fn store_relative_path(project_id: Option<&str>) -> Result<PathBuf, ProjectResolveError> {
155
+ Ok(PathBuf::from(resolve_state_dir_name(project_id)?)
156
+ .join("state")
157
+ .join("worktree-runtime")
158
+ .join("bindings.json"))
159
+ }
160
+
161
+ pub fn state_dir(repo_root: Option<&str>) -> Result<String, ProjectResolveError> {
162
+ let root = resolve_root(repo_root);
163
+ if root.is_empty() {
164
+ return Ok(String::new());
165
+ }
166
+ let normalized_root = normalize_path(&root);
167
+ let project_id = resolve_project_id(Some(&normalized_root))?;
168
+ let state_name = resolve_state_dir_name(Some(&project_id))?;
169
+ Ok(PathBuf::from(&normalized_root)
170
+ .join(&state_name)
171
+ .to_string_lossy()
172
+ .to_string())
173
+ }
174
+
175
+ pub use crate::infrastructure::github_policy_loader::{
176
+ resolve_github_policy, resolve_main_branches,
177
+ };