shep 0.1.13

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
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
//! The shapes whistle's tools return.
//!
//! Structural twins of `shep_core`'s own types, field for field and value for
//! value, with `schemars::JsonSchema` derived on top so rmcp can declare each
//! tool's output schema. [`SheepRow`] and `ProcessInfo` serialize to
//! byte-identical JSON, pinned by this module's own equality tests.
//!
//! **Why twins and not a `schemars` derive on `ProcessInfo` itself.** That
//! would put a schema-generation dependency into shep-core — a wire-protocol
//! crate — for a CLI concern, and shep-core's types are the wire contract for
//! the daemon socket, not for MCP. A twin plus an equality test is the cheaper
//! half of that trade, and the test is what stops the two drifting.
//!
//! **Why the vocabulary is reused when the envelope is not.** MCP carries its
//! own envelope: `CallToolResult`, with `structuredContent` and a per-tool
//! output schema. Nesting `output::OutputEnvelope` inside it would make the
//! declared schema describe `schema_version` and `command`, two fields that
//! mean everything to a shell script and nothing to an agent — and would
//! couple `SCHEMA_VERSION`, which is a promise to people running `jq` over
//! `shep flock --format json`, to whistle's contract. Different consumers,
//! different envelopes, one vocabulary.

use schemars::JsonSchema;
use serde::Serialize;
use shep_core::barks::{Bark, SinkOutcome};
use shep_core::protocol::{DogSource, ExitInfo, Lamb, ProcessInfo};

use crate::dog::metrics::HostReading;

/// Every list-shaped tool's payload: rows under a named field.
///
/// **Not a bare `Vec`.** `Json<T>` hands `T` straight to
/// `CallToolResult::structured`, which puts it in `structured_content` —
/// `structuredContent` on the wire, which MCP types as an object. A `Vec`
/// would put a JSON array there. rmcp 3.1.2 does not stop it (its
/// `schema_for_output` stopped validating root types per SEP-2106), so this
/// would be wrong quietly rather than loudly, which is worse.
///
/// It also leaves room: a listing that later needs a `total` or a
/// `truncated` beside its rows can grow one without changing the tool's
/// output shape from array to object, which IS a breaking change for a
/// consumer.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FlockListing {
    /// The matched sheep and dogs, in the order the shepherd reported them.
    pub flock: Vec<SheepRow>,
}

/// `list_barks`' payload. Same rule, same reason as [`FlockListing`].
#[derive(Debug, Serialize, JsonSchema)]
pub struct BarkListing {
    /// The most recent alerts, oldest first.
    pub barks: Vec<BarkRow>,
}

/// One sheep, exactly as `shep flock --format json` renders it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SheepRow {
    /// Stable numeric id.
    pub id: u32,
    /// The sheep's name.
    pub name: String,
    /// One of `starting`, `online`, `stopping`, `stopped`, `errored`,
    /// `waiting-restart`.
    pub status: String,
    /// OS pid while running.
    pub pid: Option<u32>,
    /// Restarts since registration.
    pub restarts: u32,
    /// Milliseconds since the last successful start.
    pub uptime_ms: u64,
    /// Fold membership.
    pub fold: Option<String>,
    /// Resolved stdout log path.
    pub out_file: Option<String>,
    /// Resolved stderr log path.
    pub err_file: Option<String>,
    /// Tree CPU as a percentage of one core; absent until a baseline exists.
    pub cpu_percent: Option<f32>,
    /// Tree resident set size in bytes.
    pub memory_bytes: Option<u64>,
    /// Present when this row is a dog rather than a sheep.
    pub dog: Option<DogRow>,
    /// Process-tree members, when the reply walked for them (`describe`
    /// does, `list` does not).
    pub lambs: Option<Vec<LambRow>>,
    /// How this sheep's process most recently stopped; absent while it has
    /// never exited under this daemon.
    pub last_exit: Option<ExitInfoRow>,
    /// The marker a dog has asked to have painted beside this sheep; absent
    /// when none has. Opaque text the daemon validated but never parsed.
    pub smit: Option<String>,
}

/// Where a dog came from. Mirrors `DogSource`'s tagged wire shape exactly.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DogRow {
    /// An argv branch of the shep binary itself.
    BuiltIn,
    /// A binary an operator adopted.
    Adopted {
        /// The path, as the operator gave it to `shep adopt`.
        path: String,
    },
    /// A source kind this build predates.
    ///
    /// `DogSource` is `#[non_exhaustive]` (IR-20), so `From<&DogSource>`
    /// cannot be a two-arm match — the compiler refuses it. This mirrors
    /// `output::rows::dog_source_label`'s own "unknown" fallback for the
    /// same enum, so a future daemon reporting a source kind this whistle
    /// predates gets a row rather than a build failure.
    Unknown,
}

/// One process the OS reports as a descendant of a sheep.
#[derive(Debug, Serialize, JsonSchema)]
pub struct LambRow {
    /// The lamb's own pid.
    pub pid: u32,
    /// The executable's name, as the OS reports it. Never its command line.
    pub name: String,
}

/// Why a sheep's process most recently stopped. Mirrors `ExitInfo`'s wire
/// shape exactly.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExitInfoRow {
    /// The process's own exit code, on a normal exit.
    pub code: Option<i32>,
    /// The raw unix signal number that ended it, when it did not exit on
    /// its own.
    pub signal: Option<i32>,
}

impl From<&ProcessInfo> for SheepRow {
    fn from(info: &ProcessInfo) -> Self {
        Self {
            id: info.id,
            name: info.name.clone(),
            status: info.status.to_string(),
            pid: info.pid,
            restarts: info.restarts,
            uptime_ms: info.uptime_ms,
            fold: info.fold.clone(),
            out_file: info.out_file.clone(),
            err_file: info.err_file.clone(),
            cpu_percent: info.cpu_percent,
            memory_bytes: info.memory_bytes,
            dog: info.dog.as_ref().map(DogRow::from),
            lambs: info
                .lambs
                .as_ref()
                .map(|lambs| lambs.iter().map(LambRow::from).collect()),
            last_exit: info.last_exit.as_ref().map(ExitInfoRow::from),
            smit: info.smit.clone(),
        }
    }
}

impl From<&ExitInfo> for ExitInfoRow {
    fn from(exit: &ExitInfo) -> Self {
        Self {
            code: exit.code,
            signal: exit.signal,
        }
    }
}

impl From<&DogSource> for DogRow {
    fn from(source: &DogSource) -> Self {
        match source {
            DogSource::BuiltIn => Self::BuiltIn,
            DogSource::Adopted { path } => Self::Adopted { path: path.clone() },
            _ => Self::Unknown,
        }
    }
}

impl From<&Lamb> for LambRow {
    fn from(lamb: &Lamb) -> Self {
        Self {
            pid: lamb.pid,
            name: lamb.name.clone(),
        }
    }
}

/// One alert, exactly as `shep barks --format json` renders it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct BarkRow {
    /// Unix millis when the alert fired.
    pub at_ms: u64,
    /// The rule that fired, or `daemon` when the shepherd wrote this itself.
    pub rule: String,
    /// What it is about: a sheep's name, or a dog's.
    pub subject: String,
    /// The human-readable line.
    pub message: String,
    /// Which sinks took it. Empty when the shepherd wrote the record itself.
    pub sinks: Vec<SinkOutcomeRow>,
}

/// What one sink made of one alert. Names the sink by its
/// `[dog.bark.sinks]` config key, never by its webhook URL — the property
/// `Bark`'s own doc calls the reason that type is safe to print, carried
/// across to the twin so it stays true here.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SinkOutcomeRow {
    /// The sink's name from `[dog.bark.sinks]`.
    pub sink: String,
    /// `None` when it was delivered; the failure otherwise.
    pub error: Option<String>,
}

impl From<&Bark> for BarkRow {
    fn from(bark: &Bark) -> Self {
        Self {
            at_ms: bark.at_ms,
            rule: bark.rule.clone(),
            subject: bark.subject.clone(),
            message: bark.message.clone(),
            sinks: bark.sinks.iter().map(SinkOutcomeRow::from).collect(),
        }
    }
}

impl From<&SinkOutcome> for SinkOutcomeRow {
    fn from(outcome: &SinkOutcome) -> Self {
        Self {
            sink: outcome.sink.clone(),
            error: outcome.error.clone(),
        }
    }
}

/// What `get_metrics` returns: the flock's own numbers plus the machine's.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MetricsReading {
    /// The shepherd's crate version, from the handshake.
    ///
    /// From [`super::shepherd::Shepherd::call_with_ack`], not from the
    /// reply: the handshake lives on the `Client` (`Client::daemon() ->
    /// &HelloAck`, shep-client/src/client.rs:175) and plain `call` drops the
    /// client before it returns, so `get_metrics` would have no way to fill
    /// this field.
    pub daemon_version: String,
    /// The shepherd's pid, from the same handshake and the same call.
    pub daemon_pid: u32,
    /// Every registered entry, sheep and dogs alike.
    pub flock: Vec<SheepRow>,
    /// Host totals, absent on a platform `sysinfo` does not support.
    pub host: Option<HostRow>,
}

/// The machine the flock runs on.
#[derive(Debug, Serialize, JsonSchema)]
pub struct HostRow {
    /// Total physical memory in bytes.
    pub memory_total_bytes: u64,
    /// Memory in use, as the platform reports it.
    pub memory_used_bytes: u64,
    /// How many processes the host is running, the flock included.
    pub processes: u64,
    /// Seconds since the host booted.
    pub uptime_seconds: u64,
}

impl From<&HostReading> for HostRow {
    fn from(host: &HostReading) -> Self {
        Self {
            memory_total_bytes: host.memory_total_bytes,
            memory_used_bytes: host.memory_used_bytes,
            // `usize -> u64`: infallible on every target this workspace
            // ships (macOS/Linux/Windows, all 64-bit) — a live process
            // count is nowhere near either width's ceiling, so a
            // `try_from` here would exist to handle a case that cannot
            // occur on any target in `docs/idiomatic-rust.md`'s matrix.
            processes: host.processes as u64,
            uptime_seconds: host.uptime_seconds,
        }
    }
}

/// What `tail_bleats` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct BleatTail {
    /// The sheep this came from.
    pub name: String,
    /// The id it resolved to.
    pub id: u32,
    /// Lines from the stdout log, oldest first. Empty when the file is
    /// missing or the sheep never had one.
    pub out: Vec<String>,
    /// Lines from the stderr log, oldest first.
    pub err: Vec<String>,
    /// True when the tail was cut short — by the line cap, by the 256 KiB
    /// read window, or both — rather than reaching the start of the file.
    /// A model that cannot tell "this is all of it" from "this is the last
    /// 50" will draw the wrong conclusion from a quiet log.
    pub truncated: bool,
}

#[cfg(test)]
mod tests {
    use super::*;
    use shep_core::protocol::{DogSource, Lamb, ProcessInfo};
    use shep_core::status::ProcStatus;

    /// fails the moment whistle's view of a sheep drifts from the CLI's.
    ///
    /// This is DEEP equality of the serialized values, not a key-set check:
    /// a field that keeps its name and changes its shape (`status` becoming
    /// a struct, `dog` losing its tag) fails here too. `shep describe
    /// --format json` and `describe_sheep` describe the same sheep in the
    /// same words, or this reddens and somebody decides which one is right.
    ///
    /// It also catches the additive case, which is the likely one: a
    /// fourteenth field on `ProcessInfo` makes this fail with a missing key
    /// until `SheepRow` carries it or a comment here says why it does not.
    #[test]
    fn a_sheep_row_serializes_exactly_as_process_info_does() {
        let info = ProcessInfo::builder(7, "api", ProcStatus::WaitingRestart)
            .pid(Some(4242))
            .restarts(3)
            .uptime_ms(61_000)
            .fold(Some("web".to_string()))
            .out_file(Some("/tmp/api-out.log".to_string()))
            .err_file(Some("/tmp/api-err.log".to_string()))
            .cpu_percent(Some(12.5))
            .memory_bytes(Some(1024 * 1024))
            .dog(Some(DogSource::Adopted {
                path: "/usr/local/bin/dog".to_string(),
            }))
            .lambs(Some(vec![Lamb::new(4243, "node")]))
            .build();

        assert_eq!(
            serde_json::to_value(SheepRow::from(&info)).unwrap(),
            serde_json::to_value(&info).unwrap(),
            "whistle and `--format json` must describe a sheep identically"
        );
    }

    /// fails if the every-field-populated case above is the only one that
    /// holds. A stopped sheep has `None` in six places, and a twin that
    /// rendered `null` where `ProcessInfo` renders `null` for a different
    /// reason would pass the case above and fail here.
    #[test]
    fn an_empty_sheep_row_serializes_exactly_as_process_info_does_too() {
        let info = ProcessInfo::builder(1, "idle", ProcStatus::Stopped).build();
        assert_eq!(
            serde_json::to_value(SheepRow::from(&info)).unwrap(),
            serde_json::to_value(&info).unwrap()
        );
    }

    /// fails if the schema stops describing what the struct emits. rmcp
    /// hands this schema to the model as the tool's declared output shape;
    /// a schema missing a field the tool returns teaches the model wrong.
    #[test]
    fn the_generated_schema_names_every_field_the_row_carries() {
        let schema = serde_json::to_value(schemars::schema_for!(SheepRow)).unwrap();
        let properties = schema["properties"].as_object().expect("an object schema");
        let info = ProcessInfo::builder(1, "idle", ProcStatus::Stopped).build();
        let emitted = serde_json::to_value(&info).unwrap();
        for key in emitted.as_object().unwrap().keys() {
            assert!(
                properties.contains_key(key),
                "the schema is missing `{key}`, which the tool returns"
            );
        }
    }

    /// fails if a tool's declared shape stops being one MCP will accept.
    ///
    /// Two halves, and they are different rules in rmcp 3.1.2:
    ///
    /// - **Output.** `structuredContent` is an OBJECT on the wire (rmcp's
    ///   own field doc, model.rs:3802-3803), and `Json<T>` puts `T` there
    ///   verbatim via `CallToolResult::structured` (model.rs:3963-3971).
    ///   rmcp will not stop a `Vec`: 3.1.2's `schema_for_output`
    ///   deliberately does not validate the root type (common.rs:109-120,
    ///   per SEP-2106), so the failure would be a wire-shape violation a
    ///   strict client rejects and a lenient one silently takes — the worst
    ///   kind. Hence the wrappers, and hence this test rather than a
    ///   comment.
    /// - **Input.** `schema_for_input` DOES validate (common.rs:77-96) and
    ///   the `#[tool]` macro `panic!`s on the `Err` during router
    ///   construction (rmcp-macros/tool.rs:200-208) — i.e. inside
    ///   `Whistle::new`, on every startup and in the first line of every
    ///   test in Tasks 6-10. Every argument type here is a plain struct so
    ///   this holds by construction, which is exactly what was said about
    ///   the output side before it turned out to be wrong.
    #[test]
    fn every_declared_tool_shape_is_object_rooted() {
        for (label, schema) in [
            ("FlockListing", schemars::schema_for!(FlockListing)),
            ("BarkListing", schemars::schema_for!(BarkListing)),
            ("MetricsReading", schemars::schema_for!(MetricsReading)),
            ("BleatTail", schemars::schema_for!(BleatTail)),
        ] {
            let value = serde_json::to_value(schema).unwrap();
            assert_eq!(
                value["type"], "object",
                "{label} is a tool's declared output and must be object-rooted"
            );
        }
    }

    /// fails if a bark row drifts from `shep barks --format json`.
    #[test]
    fn a_bark_row_serializes_exactly_as_a_bark_does() {
        let bark = Bark {
            at_ms: 1_700_000_000_000,
            rule: "restart-loop".to_string(),
            subject: "api".to_string(),
            message: "api restarted 5 times in 60s".to_string(),
            sinks: vec![
                SinkOutcome {
                    sink: "ops-slack".to_string(),
                    error: None,
                },
                SinkOutcome {
                    sink: "pager".to_string(),
                    error: Some("502 from the webhook".to_string()),
                },
            ],
        };
        assert_eq!(
            serde_json::to_value(BarkRow::from(&bark)).unwrap(),
            serde_json::to_value(&bark).unwrap()
        );
    }
}