trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
//! Registry-B project methods for [`DaemonClient`] (DOC-35 §3.1/§4/§6, #2115/#2114).
//!
//! Why: the `tm projects list/register/show/status/config` CLI verbs are thin
//! HTTP clients (DOC-35 §1.3). Wiring each endpoint once here — as typed
//! [`DaemonClient`] methods — keeps every consumer (the CLI today, the TUI next)
//! off hand-rolled `reqwest` calls, exactly as the managed-session methods in the
//! sibling `managed.rs` do. This file lives beside `mod.rs` (not in it) so the
//! near-cap client `mod.rs` stays under the 500-SLOC bar; it reaches the
//! `base`/`http` fields because they are `pub(in crate::client::http_client)`.
//! What: the registry-B response DTOs the client owns ([`ProjectStatusWire`] and
//! its parts, plus [`RegisterProjectArgs`]/[`PatchProjectArgs`] for the
//! POST/PATCH bodies) and five methods: [`DaemonClient::registry_list_projects`],
//! [`DaemonClient::registry_register_project`], [`DaemonClient::registry_get_project`],
//! [`DaemonClient::registry_patch_project`], and [`DaemonClient::project_status`].
//! The status DTO deliberately does NOT `deny_unknown_fields` so the additive
//! Deliverable/Milestone histogram fields #2382 adds to the endpoint deserialize
//! cleanly against an older client (wire-compat, forward-tolerant).
//! Test: `project_status_wire_tolerates_additive_fields`,
//! `register_args_serializes_to_wire_shape`,
//! `patch_args_serializes_double_option_semantics`, `projects_list_deserializes`
//! in the `tests` submodule; live HTTP via the daemon's own route tests.

use anyhow::Context;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use super::DaemonClient;
use super::error::response_or_body_error;
use crate::project::Project;

/// Serializable body for `POST /api/v1/projects` (registry-B register).
///
/// Why: `tm projects register` builds this from its flags; a typed struct keeps
/// the wire body checkable in a unit test rather than a hand-built `json!`.
/// What: mirrors the daemon's `RegisterProjectBody` — `name`/`repo_url` required,
/// the rest optional and omitted from the JSON when `None`.
/// Test: `register_args_serializes_to_wire_shape`.
#[derive(Debug, Clone, Serialize)]
pub struct RegisterProjectArgs {
    /// Registry key.
    pub name: String,
    /// Full repository URL.
    pub repo_url: String,
    /// Default branch; the daemon defaults to `"main"` when omitted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default_branch: Option<String>,
    /// Free-form description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Classification tags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    /// Technology-stack hint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stack_hint: Option<String>,
    /// Preferred `gh` login (#2081).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gh_user: Option<String>,
    /// GitHub account login pinned for this project's spawned sessions (#3025).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gh_account: Option<String>,
    /// Scoped `gh` config home written to `github.config_dir` and injected as
    /// `GH_CONFIG_DIR` at spawn (#5851).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gh_config_dir: Option<std::path::PathBuf>,
    /// "Launch on main" opt-out (#3455): `None`/`Some(true)` → default
    /// worktree isolation; `Some(false)` → sessions run directly in the
    /// resolved local checkout, no worktree.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub worktree: Option<bool>,
}

/// Serializable body for `PATCH /api/v1/projects/{name}` (registry-B partial
/// update, #2114, DOC-35 §6).
///
/// Why: `tm projects config <name> set/unset` and the TUI config form (#2120)
/// build this from their flags; mirrors the daemon's `PatchProjectBody` wire
/// shape exactly, including the double-`Option` unset story for
/// `description`/`stack_hint`/`gh_user` and the `tags_add`/`tags_remove`
/// add/remove pair (§6: "no free-text replace-whole-list footgun" — there is
/// deliberately no plain `tags` replacement field).
/// What: every field optional and omitted from the JSON when the OUTER
/// `Option` is `None` (`skip_serializing_if`); for the three double-`Option`
/// fields, `Some(None)` serializes as JSON `null` (serde's stock `Option<T>`
/// `Serialize` impl already does this — no custom serializer is needed on the
/// SEND side, only on the daemon's receive side), clearing the field, while
/// `Some(Some(v))` serializes as the value. `name` is intentionally NOT a
/// field here: the identity key is always the `{name}` path segment
/// [`DaemonClient::registry_patch_project`] takes separately, so this DTO's
/// shape cannot even express the (always-rejected) name-change attempt.
/// Test: `patch_args_serializes_double_option_semantics`,
/// `patch_args_omits_absent_fields`.
#[derive(Debug, Clone, Default, Serialize)]
pub struct PatchProjectArgs {
    /// New repository URL, if changing (rejected by the daemon when blank).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo_url: Option<String>,
    /// New default branch, if changing (rejected by the daemon when blank).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default_branch: Option<String>,
    /// `None` = unchanged; `Some(None)` = clear; `Some(Some(v))` = set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<Option<String>>,
    /// Same three-state semantics as `description`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stack_hint: Option<Option<String>>,
    /// Same three-state semantics as `description` (#2081; `gh auth status`
    /// validation is deferred to #2121).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gh_user: Option<Option<String>>,
    /// Same three-state semantics as `description`, for the pinned spawn-time
    /// `gh` account (#3025).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gh_account: Option<Option<String>>,
    /// "Launch on main" opt-out (#3455): `None` = unchanged; `Some(bool)` =
    /// set. Plain (single) `Option`, unlike the double-`Option` string
    /// fields above — see the daemon's `PatchProjectBody::worktree` doc for
    /// why this field has no separate "clear" story.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub worktree: Option<bool>,
    /// Tags to add, deduplicated server-side against the current set. A
    /// blank/whitespace-only entry rejects the whole PATCH with 400.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags_add: Option<Vec<String>>,
    /// Tags to remove; applied AFTER `tags_add` server-side. Same
    /// blank-rejection rule as `tags_add`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags_remove: Option<Vec<String>>,
}

/// Per-state session histogram (client mirror of the daemon `SessionStateCounts`).
///
/// Why: the `tm projects status` rollup needs the per-state counts; every field
/// is `#[serde(default)]` so a daemon that renames or drops a state (unlikely,
/// but forward-safe) still deserializes.
/// What: one count per lifecycle state plus the total.
/// Test: `project_status_wire_tolerates_additive_fields`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SessionStateCountsWire {
    /// Sessions in `Provisioning`.
    #[serde(default)]
    pub provisioning: usize,
    /// Sessions in `Active`.
    #[serde(default)]
    pub active: usize,
    /// Sessions in `Stopped`.
    #[serde(default)]
    pub stopped: usize,
    /// Sessions in `Errored`.
    #[serde(default)]
    pub errored: usize,
    /// Sessions in `Decommissioned` (tombstones).
    #[serde(default)]
    pub decommissioned: usize,
    /// Total bound session records.
    #[serde(default)]
    pub total: usize,
}

/// Config-completeness flags (client mirror of the daemon `ProjectConfigFlags`).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProjectConfigFlagsWire {
    /// Whether `gh_user` is set.
    #[serde(default)]
    pub gh_user_set: bool,
    /// Whether the per-project `gh` identity binding is set.
    #[serde(default)]
    pub github_binding_set: bool,
    /// Whether the pinned spawn-time `gh` account (#3025) is set.
    #[serde(default)]
    pub gh_account_set: bool,
}

/// Deserialized `GET /api/v1/projects/{name}/status` rollup.
///
/// Why: the daemon's `ProjectStatusResponse` is `Serialize`-only and lives in the
/// daemon module; the client needs its own `Deserialize` mirror. It is
/// intentionally forward-tolerant — no `deny_unknown_fields` — so the additive
/// `deliverables`/`milestones` histogram fields #2382 adds land in an older client
/// without error (the client simply ignores fields it does not model).
/// What: the project identity, the session histogram, the newest activity
/// timestamp, and the config flags.
/// Test: `project_status_wire_tolerates_additive_fields`.
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectStatusWire {
    /// Registered project name.
    #[serde(default)]
    pub project_name: String,
    /// Repository URL.
    #[serde(default)]
    pub repo_url: String,
    /// Per-state session histogram.
    #[serde(default)]
    pub sessions: SessionStateCountsWire,
    /// Most recent activity across the project's sessions, if any.
    #[serde(default)]
    pub last_activity_at: Option<DateTime<Utc>>,
    /// Config-completeness flags.
    #[serde(default)]
    pub config: ProjectConfigFlagsWire,
}

/// Wire wrapper for `GET /api/v1/projects` (`{ projects, count }`).
#[derive(Debug, Deserialize)]
struct ProjectsListWire {
    #[serde(default)]
    projects: Vec<Project>,
}

impl DaemonClient {
    /// List registry-B projects via `GET /api/v1/projects`, optional tag filter.
    ///
    /// Why: backs `tm projects list [--tag <t>]` (#2115). Tag filtering is applied
    /// client-side (a pure `retain`) so the endpoint stays a plain list and the
    /// filter rule lives in one place.
    /// What: GETs the collection, returns the `projects` array; when `tag` is
    /// `Some`, keeps only projects whose `tags` contain it.
    /// Test: `projects_list_deserializes`; live HTTP via the daemon route tests.
    pub async fn registry_list_projects(&self, tag: Option<&str>) -> anyhow::Result<Vec<Project>> {
        let url = "/api/v1/projects".to_string();
        let resp = self.get(&url).send().await?;
        let body: ProjectsListWire = response_or_body_error(resp)
            .await?
            .json()
            .await
            .context("deserialize project list")?;
        let mut projects = body.projects;
        if let Some(tag) = tag {
            projects.retain(|p| p.tags.iter().any(|t| t == tag));
        }
        Ok(projects)
    }

    /// Register (idempotent upsert) a project via `POST /api/v1/projects`.
    ///
    /// Why: backs `tm projects register` (#2115). The daemon upserts on `name` and
    /// preserves any existing `gh`/commit identity binding.
    /// What: POSTs `args`, returns the stored [`Project`].
    /// Test: `register_args_serializes_to_wire_shape`; live HTTP via the daemon
    /// route tests.
    pub async fn registry_register_project(
        &self,
        args: &RegisterProjectArgs,
    ) -> anyhow::Result<Project> {
        let url = "/api/v1/projects".to_string();
        let resp = self.post(&url).json(args).send().await?;
        let project: Project = response_or_body_error(resp)
            .await?
            .json()
            .await
            .context("deserialize registered project")?;
        Ok(project)
    }

    /// Fetch one project via `GET /api/v1/projects/{name}`.
    ///
    /// Why: backs the config half of `tm projects show` (#2115).
    /// What: GETs the point-lookup, returns the [`Project`]; a 404 surfaces as an
    /// error via [`response_or_body_error`], carrying the daemon's body text.
    /// Test: live HTTP via the daemon route tests.
    pub async fn registry_get_project(&self, name: &str) -> anyhow::Result<Project> {
        let url = format!("/api/v1/projects/{name}");
        let resp = self.get(&url).send().await?;
        let project: Project = response_or_body_error(resp)
            .await?
            .json()
            .await
            .context("deserialize project")?;
        Ok(project)
    }

    /// Partially update a project via `PATCH /api/v1/projects/{name}`.
    ///
    /// Why: backs `tm projects config <name> set/unset` and the TUI config form
    /// (#2120) — both are thin clients over the daemon's single PATCH
    /// implementation (§6). See [`PatchProjectArgs`] for the field-by-field
    /// unset/tags contract.
    /// What: PATCHes `args` to the named project, returns the updated
    /// [`Project`]; a 404 (unknown project) or 400 (blank required field, or a
    /// body that tried to change `name`) surfaces as an [`anyhow::Error`] via
    /// [`response_or_body_error`] carrying the daemon's actual rejection
    /// message (#2485) — e.g. `"400 Bad Request: repo_url must not be
    /// empty"` — rather than a bare status line.
    /// Test: `patch_rejects_blank_repo_url_surfaces_server_message` in
    /// `tests/project_registry_routes.rs`; live HTTP via the other daemon
    /// route tests there.
    pub async fn registry_patch_project(
        &self,
        name: &str,
        args: &PatchProjectArgs,
    ) -> anyhow::Result<Project> {
        let url = format!("/api/v1/projects/{name}");
        let resp = self.patch(&url).json(args).send().await?;
        let project: Project = response_or_body_error(resp)
            .await?
            .json()
            .await
            .context("deserialize patched project")?;
        Ok(project)
    }

    /// Fetch the deterministic status rollup via `GET .../{name}/status`.
    ///
    /// Why: backs `tm projects status` (#2115). The [`ProjectStatusWire`] target is
    /// forward-tolerant of #2382's additive histogram fields.
    /// What: GETs the rollup, returns the parsed [`ProjectStatusWire`].
    /// Test: `project_status_wire_tolerates_additive_fields`; live HTTP via the
    /// daemon route tests.
    pub async fn project_status(&self, name: &str) -> anyhow::Result<ProjectStatusWire> {
        let url = format!("/api/v1/projects/{name}/status");
        let resp = self.get(&url).send().await?;
        let status: ProjectStatusWire = response_or_body_error(resp)
            .await?
            .json()
            .await
            .context("deserialize project status")?;
        Ok(status)
    }
}

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

    /// The status DTO ignores unknown fields, so #2382's additive
    /// `deliverables`/`milestones` histograms deserialize against this client.
    #[test]
    fn project_status_wire_tolerates_additive_fields() {
        let json = serde_json::json!({
            "project_name": "widget",
            "repo_url": "https://github.com/acme/widget",
            "sessions": { "provisioning": 1, "active": 2, "stopped": 0,
                          "errored": 0, "decommissioned": 1, "total": 4 },
            "last_activity_at": "2026-07-10T12:00:00Z",
            "config": { "gh_user_set": true, "github_binding_set": false },
            // Fields #2382 (Wave 2) adds — an older client must ignore them.
            "deliverables": { "proposed": 3, "complete": 1 },
            "milestones": { "in-progress": 1 }
        });
        let status: ProjectStatusWire = serde_json::from_value(json).unwrap();
        assert_eq!(status.project_name, "widget");
        assert_eq!(status.sessions.active, 2);
        assert_eq!(status.sessions.total, 4);
        assert!(status.config.gh_user_set);
        assert!(status.last_activity_at.is_some());
    }

    /// A status body missing the newer `config`/`last_activity_at` still parses
    /// (backward tolerance): every field defaults.
    #[test]
    fn project_status_wire_tolerates_missing_fields() {
        let json = serde_json::json!({
            "project_name": "widget",
            "repo_url": "https://github.com/acme/widget",
            "sessions": { "total": 0 }
        });
        let status: ProjectStatusWire = serde_json::from_value(json).unwrap();
        assert_eq!(status.sessions.total, 0);
        assert!(status.last_activity_at.is_none());
        assert!(!status.config.gh_user_set);
    }

    /// The register body omits absent optionals and keeps the required pair.
    #[test]
    fn register_args_serializes_to_wire_shape() {
        let args = RegisterProjectArgs {
            name: "widget".into(),
            repo_url: "https://github.com/acme/widget".into(),
            default_branch: Some("develop".into()),
            description: None,
            tags: Some(vec!["backend".into()]),
            stack_hint: None,
            gh_user: None,
            gh_account: None,
            gh_config_dir: None,
            worktree: None,
        };
        let v = serde_json::to_value(&args).unwrap();
        assert_eq!(v["name"], "widget");
        assert_eq!(v["repo_url"], "https://github.com/acme/widget");
        assert_eq!(v["default_branch"], "develop");
        assert_eq!(v["tags"][0], "backend");
        // Absent optionals must not appear in the body.
        assert!(v.get("description").is_none());
        assert!(v.get("stack_hint").is_none());
        assert!(v.get("gh_user").is_none());
        assert!(v.get("gh_account").is_none());
        assert!(v.get("gh_config_dir").is_none());
    }

    /// Why (#5851): `--gh-config-dir` only reaches the daemon if it survives
    /// serialization; a missing `Serialize` field would silently drop the one
    /// key that selects the account.
    /// Test: itself.
    #[test]
    fn register_args_serializes_gh_config_dir() {
        let args = RegisterProjectArgs {
            name: "widget".into(),
            repo_url: "https://github.com/acme/widget".into(),
            default_branch: None,
            description: None,
            tags: None,
            stack_hint: None,
            gh_user: None,
            gh_account: Some("acme-bot".into()),
            gh_config_dir: Some("/home/bob/.config/gh-acme".into()),
            worktree: None,
        };
        let v = serde_json::to_value(&args).unwrap();
        assert_eq!(v["gh_account"], "acme-bot");
        assert_eq!(v["gh_config_dir"], "/home/bob/.config/gh-acme");
    }

    /// The list wrapper deserializes the `{ projects, count }` shape and drops the
    /// extra `count` field the client does not model.
    #[test]
    fn projects_list_deserializes() {
        let json = serde_json::json!({
            "projects": [{
                "name": "widget",
                "repo_url": "https://github.com/acme/widget",
                "default_branch": "main"
            }],
            "count": 1
        });
        let body: ProjectsListWire = serde_json::from_value(json).unwrap();
        assert_eq!(body.projects.len(), 1);
        assert_eq!(body.projects[0].name, "widget");
    }

    /// `Some(None)` (clear) serializes as JSON `null`; `Some(Some(v))` (set)
    /// serializes as the value — the double-`Option` unset story the daemon's
    /// `deserialize_double_option` expects on the receiving end.
    #[test]
    fn patch_args_serializes_double_option_semantics() {
        let args = PatchProjectArgs {
            description: Some(None),
            stack_hint: Some(Some("rust".into())),
            ..Default::default()
        };
        let v = serde_json::to_value(&args).unwrap();
        assert!(v["description"].is_null(), "{v}");
        assert_eq!(v["stack_hint"], "rust");
    }

    /// `gh_account`'s double-`Option` set/clear semantics mirror `gh_user`'s
    /// (#3025).
    #[test]
    fn patch_args_serializes_gh_account_double_option() {
        let cleared = PatchProjectArgs {
            gh_account: Some(None),
            ..Default::default()
        };
        assert!(serde_json::to_value(&cleared).unwrap()["gh_account"].is_null());

        let set = PatchProjectArgs {
            gh_account: Some(Some("bobmatnyc".into())),
            ..Default::default()
        };
        assert_eq!(
            serde_json::to_value(&set).unwrap()["gh_account"],
            "bobmatnyc"
        );
    }

    /// `worktree` is a plain (single) `Option<bool>` — `Some(false)` must
    /// serialize as the literal JSON `false`, not `null` (#3455).
    #[test]
    fn patch_args_serializes_worktree_plain_option() {
        let args = PatchProjectArgs {
            worktree: Some(false),
            ..Default::default()
        };
        let v = serde_json::to_value(&args).unwrap();
        assert_eq!(v["worktree"], false);

        let unchanged = PatchProjectArgs::default();
        assert!(
            serde_json::to_value(&unchanged)
                .unwrap()
                .get("worktree")
                .is_none()
        );
    }

    /// Every field defaults to `None` (outer), so a default `PatchProjectArgs`
    /// serializes to an empty object — a true "change nothing" PATCH body.
    #[test]
    fn patch_args_omits_absent_fields() {
        let args = PatchProjectArgs::default();
        let v = serde_json::to_value(&args).unwrap();
        assert_eq!(v, serde_json::json!({}));
    }

    /// `tags_add`/`tags_remove` and the two required-string fields serialize
    /// plainly when present.
    #[test]
    fn patch_args_serializes_tags_and_required_fields() {
        let args = PatchProjectArgs {
            repo_url: Some("https://github.com/acme/widget2".into()),
            default_branch: Some("release".into()),
            tags_add: Some(vec!["ml".into()]),
            tags_remove: Some(vec!["oss".into()]),
            ..Default::default()
        };
        let v = serde_json::to_value(&args).unwrap();
        assert_eq!(v["repo_url"], "https://github.com/acme/widget2");
        assert_eq!(v["default_branch"], "release");
        assert_eq!(v["tags_add"][0], "ml");
        assert_eq!(v["tags_remove"][0], "oss");
    }
}