car-server-core 0.55.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
//! Which repositories the self-healing loop watches, from `~/.car/heal.toml`.
//!
//! T6 of `docs/proposals/self-healing-issue-loop.md`. Same idiom as
//! [`super::config`]: a small, tolerant TOML file the operator drops next to
//! the coder state dir, so adding a repository is an edit rather than a
//! recompile.
//!
//! ```toml
//! # Nothing runs until a target is listed. An absent or empty file is a
//! # disabled loop, which is the right default for a feature that opens pull
//! # requests.
//! [[target]]
//! repo = "Parslee-ai/car"
//! project = "car"              # a CAR-managed clone, OR:
//! # path = "/Users/me/git/car" # a checkout you already have
//! label = "self-heal"          # opt-in label; items without it are invisible
//!
//! [[target]]
//! repo = "Parslee-ai/car-releases"
//! # Issues live here; fixes land on the SOURCE repo. Without this, the
//! # "is it already covered?" check queries a tracker that holds no pull
//! # requests, and the item is never covered — forever.
//! fix_repo = "Parslee-ai/car"
//! # no project/path -> watch-only: the loop reads the queue and can act on
//! # nothing, which is the honest state for a tracker with no source.
//! label = "self-heal"
//! ```
//!
//! ## Every default here fails closed
//!
//! An unreadable file disables the loop rather than falling back to a built-in
//! target list. The failure modes are not symmetric: a loop that does nothing
//! is visible the moment someone looks for a pull request, while a loop that
//! runs against a repository the operator did not name is a surprise in
//! somebody else's tracker. There is no compiled-in default target, and
//! `car`/`car-releases` are configuration like anything else.

use serde::Deserialize;

use super::heal_intake::{is_valid_repo_spec, Checkout, HealTarget};

/// File under `CAR_HOME`.
pub const HEAL_CONFIG_FILE: &str = "heal.toml";

/// The default opt-in label when a target does not name one.
pub const DEFAULT_LABEL: &str = "self-heal";

/// The default base branch for a target that does not name one.
pub const DEFAULT_BASE: &str = "main";

#[derive(Debug, Deserialize, Default)]
struct RawConfig {
    #[serde(default)]
    target: Vec<RawTarget>,
    /// The review panel: model ids, one seat each.
    #[serde(default)]
    review_models: Vec<String>,
    /// Which engine performs the coding work.
    #[serde(default)]
    engine: Option<String>,
    /// Pin the coder's model.
    #[serde(default)]
    coder_model: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawTarget {
    repo: String,
    #[serde(default)]
    fix_repo: Option<String>,
    #[serde(default)]
    project: Option<String>,
    #[serde(default)]
    path: Option<std::path::PathBuf>,
    #[serde(default)]
    label: Option<String>,
    /// The branch a fix's pull request merges into. Defaults to
    /// [`DEFAULT_BASE`]; a repository whose default branch is `master` must say
    /// so, because the loop cannot open a pull request against a base that does
    /// not exist.
    #[serde(default)]
    base: Option<String>,
}

/// A target that was named but could not be used, and why.
///
/// Surfaced rather than dropped: a typo in a repo spec that silently removed a
/// target would present as "the loop never does anything", which is the hardest
/// class of bug to notice in something whose normal state is idle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RejectedTarget {
    pub repo: String,
    pub reason: String,
}

/// The loaded configuration.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HealConfig {
    pub targets: Vec<HealTarget>,
    pub rejected: Vec<RejectedTarget>,
    /// The review panel. Empty disables the loop — see [`Self::is_enabled`].
    pub review_models: Vec<String>,
    /// Which engine performs the coding work, as `EngineChoice::parse` reads
    /// it. `None` means the compiled default.
    ///
    /// Configurable because `auto` decides per task from the external CLIs
    /// installed on the machine, so an unattended loop would silently run a
    /// different engine on a laptop than on a server.
    pub engine: Option<String>,
    /// The coder's model, pinned. `None` uses `coder.toml` then adaptive
    /// routing — which picks a provider per request, so an unattended loop can
    /// spend a full session against an expired credential while a working one
    /// sits beside it.
    ///
    /// `None` is therefore NOT the same as "unpinned", and the assembly-time
    /// "the coder may not sit on the panel" check reads both sources for that
    /// reason (`heal_service::check_coder_pin`, car#1360).
    /// Only when neither file pins does the router choose, and then only the
    /// runtime gate on `authored_by` can catch a coder that is also a seat.
    pub coder_model: Option<String>,
}

impl HealConfig {
    /// Whether the loop has anything to do, and anything to check it with.
    ///
    /// A panel is not optional. [`super::heal_gate::decide`] refuses a panel of
    /// zero, so targets without `review_models` would produce a loop that runs
    /// a full coder session per item and then rejects every one of them —
    /// burning inference to reach a foregone conclusion, and reporting the gate
    /// as the reason. Refusing to start says the same thing for free.
    pub fn is_enabled(&self) -> bool {
        !self.targets.is_empty() && !self.review_models.is_empty()
    }

    /// Why the loop is not running, when it is configured but disabled.
    ///
    /// An idle loop and a misconfigured one look identical from outside, and
    /// the misconfigured one is the common case on first setup.
    pub fn disabled_reason(&self) -> Option<&'static str> {
        if self.targets.is_empty() {
            Some("no usable targets are configured")
        } else if self.review_models.is_empty() {
            Some(
                "no `review_models` are configured; the loop will not run a coder session it has no panel to check",
            )
        } else {
            None
        }
    }

    /// Load from `dir/heal.toml`. A missing file is an empty, disabled config.
    pub fn load(dir: &std::path::Path) -> Self {
        let path = dir.join(HEAL_CONFIG_FILE);
        let Ok(raw) = std::fs::read_to_string(&path) else {
            return Self::default();
        };
        Self::parse(&raw)
    }

    /// Parse, keeping the usable targets and reporting the rest.
    ///
    /// A malformed *file* disables the loop; one malformed *target* removes
    /// only itself. Those differ because a broken file gives no basis to guess
    /// intent, while a broken entry sits beside entries whose intent is clear,
    /// and refusing all of them would make one typo disable a working setup.
    pub fn parse(raw: &str) -> Self {
        let parsed: RawConfig = match toml::from_str(raw) {
            Ok(c) => c,
            Err(e) => {
                tracing::warn!(error = %e, "unreadable heal.toml; the self-healing loop is disabled");
                return Self::default();
            }
        };

        let mut targets = Vec::new();
        let mut rejected = Vec::new();
        for t in parsed.target {
            if !is_valid_repo_spec(&t.repo) {
                rejected.push(RejectedTarget {
                    repo: t.repo,
                    reason: "not a valid owner/name repository spec".into(),
                });
                continue;
            }
            if t.project.is_some() && t.path.is_some() {
                rejected.push(RejectedTarget {
                    repo: t.repo,
                    reason: "set `project` or `path`, not both — they name different \
                             owners of the working tree"
                        .into(),
                });
                continue;
            }
            let checkout = match (t.project, t.path) {
                (Some(p), None) => Some(Checkout::Project(p)),
                (None, Some(p)) => Some(Checkout::Local(p)),
                // Neither is legal and means watch-only.
                (None, None) => None,
                (Some(_), Some(_)) => unreachable!("rejected above"),
            };
            if let Some(fr) = &t.fix_repo {
                if !is_valid_repo_spec(fr) {
                    rejected.push(RejectedTarget {
                        repo: t.repo,
                        reason: format!("`fix_repo` {fr:?} is not a valid owner/name spec"),
                    });
                    continue;
                }
            }
            targets.push(HealTarget {
                repo: t.repo,
                fix_repo: t.fix_repo,
                checkout,
                label: t.label.unwrap_or_else(|| DEFAULT_LABEL.to_string()),
                base: t
                    .base
                    .filter(|b| !b.trim().is_empty())
                    .unwrap_or_else(|| DEFAULT_BASE.to_string()),
            });
        }
        Self {
            targets,
            rejected,
            review_models: parsed
                .review_models
                .into_iter()
                .map(|m| m.trim().to_string())
                .filter(|m| !m.is_empty())
                .collect(),
            engine: parsed
                .engine
                .map(|e| e.trim().to_string())
                .filter(|e| !e.is_empty()),
            // TRIMMED, like `review_models` above. Filtering on the trimmed
            // value while keeping the untrimmed one let `coder_model =
            // "gpt-5.5 "` survive, miss the registry, and slip past the check
            // that a coder may not sit on its own review panel — a model
            // reviewing its own output because of a stray space.
            coder_model: parsed
                .coder_model
                .map(|m| m.trim().to_string())
                .filter(|m| !m.is_empty()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A stray space must not defeat the coder-on-panel check.
    #[test]
    fn coder_model_and_engine_are_trimmed_not_merely_tested_for_blankness() {
        let c = HealConfig::parse(
            r#"
review_models = ["reviewer-a"]
coder_model = "  gpt-5.5  "
engine = "  native  "
[[targets]]
repo = "acme/one"
"#,
        );
        assert_eq!(c.coder_model.as_deref(), Some("gpt-5.5"));
        assert_eq!(c.engine.as_deref(), Some("native"));
    }

    #[test]
    fn an_absent_file_disables_the_loop() {
        let dir = tempfile::tempdir().unwrap();
        let c = HealConfig::load(dir.path());
        assert!(!c.is_enabled(), "no compiled-in targets");
        assert!(c.targets.is_empty());
    }

    #[test]
    fn there_is_no_built_in_target_list() {
        // The loop opens pull requests. Running against a repository nobody
        // named is a surprise in somebody else's tracker.
        let c = HealConfig::parse("");
        assert!(c.targets.is_empty());
    }

    #[test]
    fn a_project_target_parses() {
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "Parslee-ai/car"
            project = "car"
            label = "self-heal"
            "#,
        );
        assert_eq!(c.targets.len(), 1);
        assert_eq!(c.targets[0].repo, "Parslee-ai/car");
        assert_eq!(c.targets[0].checkout, Some(Checkout::Project("car".into())));
        assert!(c.targets[0].can_write());
    }

    #[test]
    fn a_target_with_no_checkout_is_watch_only() {
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "Parslee-ai/car-releases"
            "#,
        );
        assert_eq!(c.targets.len(), 1);
        assert!(!c.targets[0].can_write());
        assert_eq!(c.targets[0].label, DEFAULT_LABEL);
    }

    #[test]
    fn project_and_path_together_are_refused() {
        // They name different owners of the working tree; guessing which the
        // operator meant is how a loop writes somewhere unexpected.
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "acme/widgets"
            project = "widgets"
            path = "/tmp/widgets"
            "#,
        );
        assert!(c.targets.is_empty());
        assert_eq!(c.rejected.len(), 1);
        assert!(c.rejected[0].reason.contains("not both"));
    }

    #[test]
    fn a_bad_repo_spec_is_reported_not_silently_dropped() {
        // A typo that silently removed a target presents as "the loop never
        // does anything", which is the hardest bug to notice in something whose
        // normal state is idle.
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "not-a-spec"
            project = "x"
            "#,
        );
        assert!(c.targets.is_empty());
        assert_eq!(c.rejected[0].repo, "not-a-spec");
    }

    #[test]
    fn one_bad_target_does_not_disable_the_good_ones() {
        let c = HealConfig::parse(
            r#"
            review_models = ["a", "b"]

            [[target]]
            repo = "bad spec"

            [[target]]
            repo = "acme/widgets"
            project = "widgets"
            "#,
        );
        assert_eq!(c.targets.len(), 1);
        assert_eq!(c.rejected.len(), 1);
        assert!(c.is_enabled());
        assert_eq!(c.disabled_reason(), None);
    }

    #[test]
    fn targets_without_a_review_panel_do_not_enable_the_loop() {
        // `heal_gate::decide` refuses a panel of zero, so this configuration
        // would run a full coder session per item and then reject every one of
        // them — spending inference to reach a foregone conclusion and
        // reporting the gate as the reason. Refusing to start says the same
        // thing for free, and names itself.
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "acme/widgets"
            project = "widgets"
            "#,
        );
        assert_eq!(c.targets.len(), 1);
        assert!(!c.is_enabled());
        assert!(c.disabled_reason().unwrap().contains("review_models"));
    }

    #[test]
    fn the_panel_and_the_engine_are_read_from_the_file() {
        let c = HealConfig::parse(
            r#"
            review_models = ["claude-opus-5", "  ", "gpt-5.5"]
            engine = "native"

            [[target]]
            repo = "acme/widgets"
            project = "widgets"
            base = "master"
            "#,
        );
        // A blank entry would be a seat that can never answer, which the gate
        // reads as an unreachable panel member and refuses the item over.
        assert_eq!(c.review_models, vec!["claude-opus-5", "gpt-5.5"]);
        assert_eq!(c.engine.as_deref(), Some("native"));
        // The base is per target: `main` was hardcoded, and on a `master`
        // repository the pull request was opened against a branch that does
        // not exist.
        assert_eq!(c.targets[0].base, "master");
    }

    #[test]
    fn an_unnamed_base_defaults_rather_than_being_empty() {
        let c = HealConfig::parse(
            r#"
            review_models = ["a"]

            [[target]]
            repo = "acme/widgets"
            project = "widgets"
            base = "   "
            "#,
        );
        // An empty base reaches `deliver_pr` as a branch name git would
        // misread, and the refusal names the wrong thing.
        assert_eq!(c.targets[0].base, DEFAULT_BASE);
    }

    #[test]
    fn a_malformed_file_disables_the_loop_entirely() {
        // No basis to guess intent, and the safe reading of "I cannot parse
        // your instructions" is to do nothing.
        let c = HealConfig::parse("[[target]\nrepo = ");
        assert!(!c.is_enabled());
    }

    #[test]
    fn per_target_labels_are_independent() {
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "acme/widgets"
            project = "widgets"
            label = "auto-fix"

            [[target]]
            repo = "acme/gadgets"
            project = "gadgets"
            "#,
        );
        assert_eq!(c.targets[0].label, "auto-fix");
        assert_eq!(c.targets[1].label, DEFAULT_LABEL);
    }
    #[test]
    fn a_cross_repo_target_carries_where_fixes_land() {
        // Issues on a public tracker, code in the source repo. Without this the
        // coverage check queries a repository that holds no pull requests, and
        // the item is never covered — every daemon, every tick, forever.
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "Parslee-ai/car-releases"
            fix_repo = "Parslee-ai/car"
            project = "car"
            "#,
        );
        assert_eq!(c.targets.len(), 1);
        assert_eq!(c.targets[0].coverage_repo(), "Parslee-ai/car");
        assert!(c.targets[0].is_cross_repo());
    }

    #[test]
    fn a_bad_fix_repo_is_reported_not_ignored() {
        let c = HealConfig::parse(
            r#"
            [[target]]
            repo = "acme/tracker"
            fix_repo = "not a spec"
            project = "x"
            "#,
        );
        assert!(c.targets.is_empty());
        assert!(c.rejected[0].reason.contains("fix_repo"));
    }
}