wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
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
481
482
//! The document types a JSONL run emits.
//!
//! One JSON object per line, one line per thing that happened. The set is
//! CLOSED: a caller cannot invent a shape, because a consumer that has to
//! guess at shapes is back to parsing prose. Everything a command has to say
//! is one of these, and anything that does not fit is text — which is what
//! [`Record::Stdout`] and [`Record::Stderr`] are for.
//!
//! Every line carries the same envelope: a monotonic `seq`, a timestamp, and
//! the `type` that says which of these it is. `seq` is what lets a consumer
//! detect that it lost a line; a timestamp alone cannot, because two lines can
//! share one.

use serde::{Deserialize, Serialize};

/// How a thing that ended, ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
    Ok,
    Failed,
    /// Deliberately not run.
    Skipped,
    /// Stopped from outside — a cancel, a signal.
    Cancelled,
}

impl Outcome {
    pub fn label(self) -> &'static str {
        match self {
            Self::Ok => "ok",
            Self::Failed => "failed",
            Self::Skipped => "skipped",
            Self::Cancelled => "cancelled",
        }
    }
}

/// How much a message matters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
    Info,
    Warn,
    Error,
}

/// Which pipe a child's text came out of.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stream {
    Stdout,
    Stderr,
}

/// One thing that happened.
///
/// The variants divide into four groups: what this command is (`CommandStarted`
/// / `CommandFinished`), what it says (`Stdout` / `Stderr` / `Message` /
/// `Failure`), what it is doing (`PhaseStarted` / `PhaseFinished` /
/// `Progress`), and what it ran or produced (the `Subprocess*`, `Test*` and
/// `Artifact` variants).
/// One suite as the plan declares it, before it runs.
///
/// `steps` is what this suite will actually do — a toolchain with no separate
/// compile step has no `build` step, and saying it has one would put a row on
/// screen that never changes. A suite that will not run at all carries the
/// reason and no steps: it is still shown, because "this was not run, and
/// why" is a result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlannedSuite {
    pub suite: String,
    /// `build`, `test`, or both, in the order they run.
    pub steps: Vec<String>,
    /// Why this suite will not run. `steps` is empty when this is set.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub skip: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Record {
    /// The first line of every JSONL run: what is running, where,
    /// and as what. A consumer that has this can reproduce the run.
    CommandStarted {
        argv: Vec<String>,
        cwd: String,
        version: String,
        pid: u32,
    },

    /// The last line of every JSONL run. Nothing follows it.
    CommandFinished {
        outcome: Outcome,
        exit_code: i32,
        duration_ms: u64,
    },

    /// Text this command wrote for a person to read.
    Stdout { text: String },

    /// Text this command wrote to report something going sideways.
    Stderr { text: String },

    /// Something worth saying, said as data rather than as prose.
    ///
    /// `code` names the kind of message so a consumer can act on it without
    /// matching on wording; it is absent where the message is one of a kind.
    Message {
        level: Level,
        text: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        code: Option<String>,
    },

    /// The command failed, and this is why. Emitted immediately before
    /// `CommandFinished` with a failed outcome, never on its own.
    Failure {
        message: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        code: Option<String>,
    },

    /// A named stretch of work began. Phases nest by name, not by structure:
    /// the `phase` is a stable identifier a consumer can group by.
    PhaseStarted {
        phase: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        label: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        total: Option<u64>,
    },

    PhaseFinished {
        phase: String,
        outcome: Outcome,
        duration_ms: u64,
    },

    /// How far along something is. `total` is absent where the end is not
    /// known yet — which is honest, and lets a consumer show a spinner rather
    /// than a bar that lies.
    Progress {
        #[serde(skip_serializing_if = "Option::is_none")]
        phase: Option<String>,
        done: u64,
        #[serde(skip_serializing_if = "Option::is_none")]
        total: Option<u64>,
        #[serde(skip_serializing_if = "Option::is_none")]
        label: Option<String>,
    },

    /// A child process started. Its output arrives as `SubprocessOutput`
    /// carrying the same `id`, so interleaved children stay apart.
    SubprocessStarted {
        id: u64,
        argv: Vec<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        cwd: Option<String>,
        /// The operating system's process id.
        ///
        /// Always present: it is read from the spawned child, so a record that
        /// exists at all has one. It is what makes a running child something
        /// a person can find in `ps` and stop by hand — an id that only means
        /// something inside this run cannot be acted on from outside it.
        pid: u32,
    },

    SubprocessOutput {
        id: u64,
        stream: Stream,
        text: String,
    },

    SubprocessFinished {
        id: u64,
        #[serde(skip_serializing_if = "Option::is_none")]
        exit_code: Option<i32>,
        /// Set instead of `exit_code` when a signal killed it.
        #[serde(skip_serializing_if = "Option::is_none")]
        signal: Option<i32>,
        duration_ms: u64,
    },

    /// Every row a group will run, named before any of them starts.
    ///
    /// A terminal draws the group as a MATRIX: one row per suite, each showing
    /// which steps it has and what state each is in, updated in place. A
    /// consumer used to get only the transitions — a suite appeared when it
    /// started and was never announced as intended — so a page could show what
    /// had happened and never what was about to. Cursor movement is what
    /// carried "these rows are coming" on a terminal, and cursor movement does
    /// not survive being written to a file.
    ///
    /// This is that row set, sent once per group, so the same picture can be
    /// built from records by anything that reads them.
    TestGroupPlanned {
        /// The group's id, matching the `PhaseStarted` that opens it.
        group: String,
        /// The group's heading, as a person reads it.
        label: String,
        suites: Vec<PlannedSuite>,
    },

    /// One suite of the test plan started.
    TestStarted {
        suite: String,
        /// The plan group it belongs to.
        group: String,
        /// `build` or `test` — a suite is built, then run, and both can fail.
        step: String,
        /// Where this step's output is being written, as the workspace
        /// addresses it.
        ///
        /// Named at the START, not only at the end: the file is appended to
        /// from the step's first line, so a consumer watching a run can follow
        /// it while it is being written. Carrying it only on `TestFinished`
        /// meant the path arrived at the one moment nobody needed it — after
        /// the output it addresses was already complete.
        #[serde(skip_serializing_if = "Option::is_none")]
        log: Option<String>,
    },

    TestFinished {
        suite: String,
        group: String,
        step: String,
        outcome: Outcome,
        duration_ms: u64,
        /// Where the full output of this step was written.
        #[serde(skip_serializing_if = "Option::is_none")]
        log: Option<String>,
        /// What went wrong, when something did.
        #[serde(skip_serializing_if = "Option::is_none")]
        message: Option<String>,
    },

    TestSummary {
        passed: u64,
        failed: u64,
        skipped: u64,
        duration_ms: u64,
    },

    /// One test the catalogue knows about.
    ///
    /// Everything needed to show it, find it and run it: where it lives, what
    /// language it is written in, the number it claims and what it says it
    /// asserts. Emitted by `wsb catalog list`, which is how the workbench gets
    /// a browsable, runnable list rather than a count.
    CatalogueTest {
        /// The `TESTnnnn` it claims, absent when it claims none.
        #[serde(skip_serializing_if = "Option::is_none")]
        number: Option<i64>,
        /// The test function's own name.
        name: String,
        /// The repository it lives in, relative to the workspace root. The
        /// superproject is the empty string.
        repo: String,
        language: String,
        /// Relative to the repository, with the line it starts on.
        file: String,
        line: i64,
        /// What the test says it asserts. Empty when it says nothing — which
        /// is a fact about the test, not a missing field.
        description: String,
    },

    /// A table the command would have drawn on a terminal.
    ///
    /// The rows, not a picture of the rows. wsb reports most of what it knows
    /// as a labelled table — what a test run is configured to do, what a
    /// publish would sign, which tools are installed — and drawing it with box
    /// characters made that unreadable to anything but an eye. Columns are
    /// named so a consumer can lay them out itself.
    Table {
        title: String,
        columns: Vec<String>,
        /// One entry per row, each with one value per column. A row with the
        /// wrong number of values is a defect in the caller, not a shape a
        /// consumer has to handle.
        rows: Vec<Vec<String>>,
        /// The one line under the table saying what it comes to.
        #[serde(skip_serializing_if = "Option::is_none")]
        verdict: Option<Verdict>,
    },

    /// Something the run produced and a consumer may want to open: a log, a
    /// bundle, a manifest, a report.
    Artifact {
        kind: String,
        path: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        label: Option<String>,
    },

    /// One repository of a project's worktree, as `wsb worktree` made,
    /// removed, migrated or found it.
    WorktreeCheckout {
        /// `added` | `removed` | `migrated` | `listed`.
        action: String,
        /// `<project>/<worktree>`.
        worktree: String,
        /// Relative to the worktree; empty for the superproject.
        repository: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        branch: Option<String>,
        commit: String,
    },

    /// One worktree of a project, and how its submodules are kept: as
    /// worktrees of main's repositories, as separate clones, or not at all.
    WorktreeLayout {
        project: String,
        worktree: String,
        path: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        branch: Option<String>,
        shared: Vec<String>,
        cloned: Vec<String>,
        missing: Vec<String>,
        broken: Vec<String>,
    },

    /// One peer, and where the relationship with it stands.
    ///
    /// A peer is a machine, not a workspace: this says whether the two
    /// machines can talk at all. What they carry BETWEEN them is
    /// [`Record::WorkspacePeerState`], one per workspace, because a peer can
    /// be reachable while every workspace with it is blocked, and unreachable
    /// while every workspace is in perfect agreement.
    PeerState {
        peer: String,
        name: String,
        /// The public identity, as shown to an operator comparing two
        /// machines by eye. This is what pairing is checked against.
        fingerprint: String,
        /// host:port, as this machine dials it.
        address: String,
        /// `unpaired` | `offline` | `connecting` | `connected` | `error`.
        state: String,
        /// Why, when the state alone does not say. Empty is a fact.
        #[serde(skip_serializing_if = "Option::is_none")]
        detail: Option<String>,
    },

    /// One workspace, as it stands with one peer.
    ///
    /// `presence` says whether both machines even have it. `desired` is what
    /// the operator asked for and changes only by an explicit action;
    /// `observed` is what the daemon last saw. They are separate fields
    /// because they disagree routinely and collapsing them would report a
    /// wish as a fact.
    WorkspacePeerState {
        peer: String,
        workspace: String,
        /// `local_only` | `peer_only` | `shared`.
        presence: String,
        /// `disabled` | `enabled` | `paused`.
        desired: String,
        /// How many repositories of this workspace carry each observed state.
        repositories: usize,
        blocked: usize,
        conflicts: usize,
    },

    /// One service, as it stands with one peer.
    ///
    /// Separate from [`Record::PeerState`] because a peer's reachability and
    /// what it is allowed to do are different facts: a connected machine with
    /// file sync switched off is working exactly as asked, and a consumer that
    /// read only the connection would call it healthy while nothing crossed.
    PeerService {
        peer: String,
        /// `files`.
        service: String,
        /// `on` | `off`.
        switch: String,
    },

    /// One repository of one workspace, as it stands with one peer.
    ///
    /// The unit of synchronisation is the repository, not the workspace: a
    /// submodule on another branch blocks itself and nothing else. A consumer
    /// that wants "is this workspace synchronised" answers it by reading every
    /// one of these, which is the honest answer rather than a summary that
    /// hides the one repository that is stuck.
    SyncState {
        peer: String,
        workspace: String,
        /// Workspace-relative; the superproject is the empty string.
        repository: String,
        /// `disabled` | `enabled` | `paused`.
        desired: String,
        /// `idle` | `checking` | `ready` | `syncing` | `synced` | `paused` |
        /// `undecided` | `blocked` | `conflict` | `error`.
        observed: String,
        /// The blocking reason's stable code. Present exactly when `observed`
        /// is `blocked`, because a block without a reason is not actionable.
        #[serde(skip_serializing_if = "Option::is_none")]
        blocked: Option<String>,
        /// What that code means, in words.
        #[serde(skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
        pending: usize,
        conflicts: usize,
        #[serde(skip_serializing_if = "Option::is_none")]
        branch: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        head: Option<String>,
    },

    /// How far a transfer has got.
    ///
    /// Emitted while a repository is `syncing`. `files` and `bytes` are what
    /// remains, not what has been done: a consumer showing a bar wants the
    /// denominator, and a run that finishes ends with the repository's
    /// [`Record::SyncState`] rather than a progress line reading zero.
    SyncProgress {
        peer: String,
        workspace: String,
        repository: String,
        files: usize,
        bytes: u64,
    },

    /// One path both machines changed since they last agreed.
    ///
    /// A conflict is a state, not an error: both versions exist, and neither
    /// is chosen by a clock. It stays until an operator says which side wins,
    /// so this record persists across runs and reappears until resolved.
    SyncConflict {
        peer: String,
        workspace: String,
        repository: String,
        /// Repository-relative, forward slashes.
        path: String,
        /// What this machine holds: `modified` | `deleted`.
        here: String,
        /// What the peer holds.
        there: String,
    },

    /// One path that differs between this machine and a peer.
    ///
    /// This is NOT a git diff, and a consumer must not treat it as one: it
    /// compares two working trees, and says nothing about either one's
    /// relationship to a commit.
    SyncDiff {
        peer: String,
        workspace: String,
        repository: String,
        path: String,
        /// Which way it would move: `send` | `receive` | `send_deletion` |
        /// `receive_deletion` | `conflict`.
        direction: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        size: Option<u64>,
    },
}

/// What a table comes to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Verdict {
    pub message: String,
    pub level: Level,
}

/// A record with the envelope every line carries.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Line {
    /// Monotonic from 1 within one run. A gap means a line was lost.
    pub seq: u64,
    /// RFC 3339, UTC.
    pub ts: String,
    #[serde(flatten)]
    pub record: Record,
}

#[cfg(test)]
#[path = "record_tests.rs"]
mod tests;