grex-core 1.3.0

Core library for grex, the nested meta-repo manager: manifest, lockfile, scheduler, pack model, plugin traits.
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
483
484
485
486
487
488
489
490
//! Event types for the manifest log.
//!
//! Events are JSON objects with a `"op"` discriminant. Unknown fields are
//! **intentionally accepted** (no `#[serde(deny_unknown_fields)]`) so that
//! older grex binaries can still read newer logs as long as the operation
//! type is known.

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

/// Logical identifier of a pack. Stable across updates, unique per workspace.
pub type PackId = String;

/// Current manifest schema version. Bumped whenever event shapes change
/// incompatibly.
pub const SCHEMA_VERSION: &str = "1";

/// One entry in the manifest log.
///
/// Serialized form uses a lowercase `"op"` tag:
/// ```json
/// {"op":"add","ts":"...","id":"...","url":"...","path":"...","type":"...","schema_version":"1"}
/// ```
///
/// # Action audit variants (PR E)
///
/// [`Event::ActionStarted`] is appended **before** the executor runs an
/// action. [`Event::ActionCompleted`] is appended **after** success;
/// [`Event::ActionHalted`] is appended **after** failure. A dangling
/// `ActionStarted` with no matching completed/halted peer is a crash
/// candidate — see [`crate::sync::scan_recovery`].
///
/// These variants are ignored by [`crate::manifest::fold::fold`] (they do
/// not mutate pack state) so the folded projection is unchanged from the
/// pre-PR-E schema; old readers decoding a log that contains them still
/// parse successfully because the `op` discriminants are known lowercase
/// tags with plain fields (unknown fields are tolerated per module docs).
#[non_exhaustive]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Event {
    /// Register a new pack in the workspace.
    Add {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier.
        id: PackId,
        /// Upstream source URL (git remote, file path, etc).
        url: String,
        /// Workspace-relative checkout path.
        path: String,
        /// Pack flavor (e.g. `"declarative"`, `"imperative"`).
        #[serde(rename = "type")]
        pack_type: String,
        /// Schema version at time of write.
        schema_version: String,
    },
    /// Update a single field on an existing pack.
    ///
    /// `field` must be one of `"url"`, `"ref"`, `"path"`. Unknown field
    /// names are ignored by [`crate::manifest::fold::fold`] to keep forward
    /// compatibility.
    Update {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier.
        id: PackId,
        /// Field name being updated.
        field: String,
        /// New value (string or other JSON scalar).
        value: serde_json::Value,
    },
    /// Remove a pack from the workspace.
    Rm {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier.
        id: PackId,
    },
    /// Record a completed action step. Emitted on the success path of
    /// [`crate::sync::run`] along with [`Event::ActionCompleted`]; the
    /// `sha` field carries a short human summary of the step outcome
    /// (kept for backward-compat with M2 readers that folded only on this
    /// variant).
    Sync {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier.
        id: PackId,
        /// Resolved commit SHA or short action summary.
        sha: String,
    },
    /// An executor is **about to run** an action. Written before
    /// `executor.execute` so a crash mid-action leaves a discoverable
    /// trace. A dangling `ActionStarted` with no matching completed/halted
    /// peer signals a crashed run — see [`crate::sync::scan_recovery`].
    ActionStarted {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier owning the action.
        pack: PackId,
        /// 0-based index into the pack's top-level `actions` vector.
        action_idx: usize,
        /// Short action kind tag (e.g. `"symlink"`, `"mkdir"`).
        action_name: String,
    },
    /// The executor returned `Ok`. Paired with a preceding
    /// [`Event::ActionStarted`]. `result_summary` is a short
    /// human-readable string (e.g. `"performed_change"`).
    ActionCompleted {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier owning the action.
        pack: PackId,
        /// 0-based index into the pack's top-level `actions` vector.
        action_idx: usize,
        /// Short outcome summary tag.
        result_summary: String,
    },
    /// The executor returned `Err`. Paired with a preceding
    /// [`Event::ActionStarted`]. `error_summary` is the error's `Display`
    /// output truncated to a small limit so an audit trail line stays
    /// single-event-sized.
    ActionHalted {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Pack identifier owning the action.
        pack: PackId,
        /// 0-based index into the pack's top-level `actions` vector.
        action_idx: usize,
        /// Short action kind tag.
        action_name: String,
        /// Truncated error message (at most
        /// [`ACTION_ERROR_SUMMARY_MAX`] bytes).
        error_summary: String,
    },
    /// v1.2.0 Stage 1.l — A walker Phase 2 prune fired against a
    /// non-Clean consent verdict because the operator requested
    /// `--force-prune` (or the stronger `--force-prune-with-ignored`).
    /// Postmortem-only: emitted ONLY when the override flags actually
    /// consumed a non-Clean verdict; clean-consent prunes do not write
    /// this event. Tracks the dest path, the refusal kind that was
    /// overridden, and whether the stronger ignored-content override
    /// was in effect.
    ForcePruneExecuted {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Absolute path of the dest that was pruned (display form).
        path: String,
        /// Stable lowercase tag for the refusal kind that was
        /// overridden (`"dirty_tree"`, `"dirty_tree_with_ignored"`,
        /// `"sub_meta_with_dirty_children"`). `GitInProgress` is never
        /// overridable so it never appears here.
        kind: String,
        /// `true` when the stronger `--force-prune-with-ignored` was
        /// in effect at the time of the override; `false` when only
        /// the base `--force-prune` was set.
        force_prune_with_ignored: bool,
    },
    /// v1.2.1 Item 5b — `--quarantine` lifecycle: append-and-fsync'd
    /// BEFORE any byte of the recursive snapshot is written. The audit
    /// event must precede the snapshot per Lean theorem
    /// `quarantine_snapshot_precedes_delete` (proof/Grex/Quarantine.lean):
    /// `delete_licensed` only holds when this entry is durable in the
    /// log. If the audit append fails the prune aborts with no FS
    /// mutation; if the subsequent snapshot fails the prune still
    /// aborts (no `unlink(dest)`) and a [`Event::QuarantineFailed`]
    /// follow-up is logged.
    QuarantineStart {
        /// Event timestamp (matches the `ts` segment in `trash_path`).
        ts: DateTime<Utc>,
        /// Absolute path of the dest about to be snapshot-then-pruned
        /// (display form).
        src: String,
        /// Absolute path of the trash bucket the snapshot will be
        /// copied to: `<meta>/.grex/trash/<ISO8601>/<basename>/`.
        trash: String,
    },
    /// v1.2.1 Item 5b — `--quarantine` lifecycle: appended after the
    /// snapshot succeeded AND `unlink(dest)` succeeded. Pairs 1:1 with
    /// a preceding [`Event::QuarantineStart`]. Absence of this event
    /// after a Start is a forensic signal: either the snapshot or the
    /// unlink failed (look for [`Event::QuarantineFailed`] or a
    /// partial trash dir at `trash`).
    QuarantineComplete {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Absolute path of the dest that was successfully pruned
        /// after snapshot.
        src: String,
        /// Absolute path of the snapshot bucket holding the recursive
        /// copy of the pre-prune subtree.
        trash: String,
    },
    /// v1.2.1 Item 5b — `--quarantine` lifecycle: appended when the
    /// recursive snapshot or the audited unlink failed AFTER the
    /// preceding [`Event::QuarantineStart`] entry was already on disk.
    /// The original `src` MUST remain intact — the unlink either never
    /// fired (snapshot failure) or is the failure being reported here.
    /// A partial trash dir may remain at `trash` for forensics.
    QuarantineFailed {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Absolute path of the dest the prune attempted (and aborted
        /// on).
        src: String,
        /// Absolute path of the trash bucket. May exist as a partial
        /// snapshot for operator inspection; never auto-cleaned.
        trash: String,
        /// Truncated failure message (display form of the underlying
        /// I/O error). Bounded to keep one event on one line.
        error: String,
    },
    /// v1.2.5 — operator restored a quarantined snapshot back into the
    /// workspace via `grex doctor --restore-quarantine <ts>
    /// [<basename>]`. Records both the source snapshot path the operator
    /// pulled from and the dest the bytes were restored to. Fully
    /// additive variant — older readers tolerate it via the
    /// [`Event::Unknown`] fallback below (forward-compat per JSONL
    /// policy).
    QuarantineRestored {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Absolute path of the trash snapshot the bytes came from
        /// (`<meta>/.grex/trash/<ts>/<basename>/`).
        src: String,
        /// Absolute path of the dest the snapshot was restored to.
        dest: String,
    },
    /// v1.2.5 — quarantine GC sweep removed an aged trash entry. Emitted
    /// per pruned entry by [`crate::tree::quarantine::prune_quarantine`]
    /// (best-effort; failures log via tracing instead of producing this
    /// event). Fully additive — older readers tolerate via
    /// [`Event::Unknown`].
    QuarantineGcSwept {
        /// Event timestamp.
        ts: DateTime<Utc>,
        /// Absolute path of the trash entry that was deleted
        /// (`<meta>/.grex/trash/<ts>/`).
        entry: String,
        /// Age in whole days of the entry at the time of the sweep
        /// (cutoff = `now - retain_days`; entries with age > cutoff
        /// are pruned).
        age_days: u64,
    },
    /// v1.2.5 — forward-compat fallback for unknown `op` discriminants.
    /// Exists so an OLDER `grex` binary reading a NEWER log (e.g.
    /// containing variants added after this binary was built) decodes
    /// the unknown line as `Unknown` instead of failing the whole
    /// `read_all` with a `Corruption` error. The variant is unit (no
    /// fields) by design: any payload the writer included is dropped on
    /// the read side. Folding logic ([`crate::manifest::fold::fold`])
    /// already ignores audit-only variants, and accessor methods
    /// ([`Event::id`], [`Event::ts`]) return placeholder values so call
    /// sites don't need to special-case the variant. Per JSONL forward-
    /// compat policy in design.md.
    ///
    /// # `#[serde(other)]` semantics
    ///
    /// Catches unknown `op` discriminator values for forward-compat.
    /// Filtered out by [`crate::manifest::read_all`] (silently dropped
    /// regardless of position so older readers can still consume newer
    /// logs without erroring on the rest of the stream). Cannot be
    /// written back: the write-side guard in
    /// [`crate::manifest::append_event`] panics in debug builds and
    /// no-ops with a `tracing::error!` in release builds.
    #[serde(other)]
    Unknown,
}

/// Lazy-initialised empty [`PackId`] returned by [`Event::id`] for the
/// [`Event::Unknown`] forward-compat variant. Avoids a `'static` literal
/// so the return type stays `&PackId` (= `&String`) without the variant
/// having to carry a synthetic id field.
fn empty_pack_id() -> &'static PackId {
    static EMPTY: std::sync::OnceLock<PackId> = std::sync::OnceLock::new();
    EMPTY.get_or_init(String::new)
}

/// Max bytes retained in [`Event::ActionHalted::error_summary`].
///
/// Truncation keeps one halt record on one JSONL line without
/// pathological blowup when an executor surfaces a multi-KB error
/// (e.g. captured stderr from an exec failure).
pub const ACTION_ERROR_SUMMARY_MAX: usize = 2048;

impl Event {
    /// Return the pack id the event applies to.
    ///
    /// Action-audit variants return the `pack` field; legacy variants
    /// return their `id`. Workspace-scoped variants
    /// ([`Event::ForcePruneExecuted`]) return the dest `path` as their
    /// identifier — there is no single owning pack for an audit-only
    /// override record.
    pub fn id(&self) -> &PackId {
        match self {
            Event::Add { id, .. }
            | Event::Update { id, .. }
            | Event::Rm { id, .. }
            | Event::Sync { id, .. } => id,
            Event::ActionStarted { pack, .. }
            | Event::ActionCompleted { pack, .. }
            | Event::ActionHalted { pack, .. } => pack,
            Event::ForcePruneExecuted { path, .. } => path,
            // v1.2.1 Item 5b — quarantine variants carry the dest as
            // their identifier (`src`); same audit-only pattern as
            // `ForcePruneExecuted` (no owning pack id).
            Event::QuarantineStart { src, .. }
            | Event::QuarantineComplete { src, .. }
            | Event::QuarantineFailed { src, .. }
            | Event::QuarantineRestored { src, .. } => src,
            // v1.2.5 — GC sweep variant carries the trash entry path
            // as its identifier (no owning pack id; same audit-only
            // pattern).
            Event::QuarantineGcSwept { entry, .. } => entry,
            // v1.2.5 — forward-compat fallback. No payload to
            // surface; return a stable empty id so legacy callers
            // that group by `id()` see the line as a non-pack-scoped
            // entry.
            Event::Unknown => empty_pack_id(),
        }
    }

    /// Return the event timestamp.
    pub fn ts(&self) -> DateTime<Utc> {
        match self {
            Event::Add { ts, .. }
            | Event::Update { ts, .. }
            | Event::Rm { ts, .. }
            | Event::Sync { ts, .. }
            | Event::ActionStarted { ts, .. }
            | Event::ActionCompleted { ts, .. }
            | Event::ActionHalted { ts, .. }
            | Event::ForcePruneExecuted { ts, .. }
            | Event::QuarantineStart { ts, .. }
            | Event::QuarantineComplete { ts, .. }
            | Event::QuarantineFailed { ts, .. }
            | Event::QuarantineRestored { ts, .. }
            | Event::QuarantineGcSwept { ts, .. } => *ts,
            // v1.2.5 — `Unknown` carries no payload; return the Unix
            // epoch as the stable placeholder so consumers that order
            // by `ts()` still get a well-defined value.
            Event::Unknown => DateTime::<Utc>::from_timestamp(0, 0).unwrap_or_default(),
        }
    }
}

/// Current resolved state of a single pack.
///
/// Produced by folding the manifest log. **Not serialized** — this is an
/// in-memory projection; the lockfile has its own on-disk shape.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackState {
    /// Pack identifier.
    pub id: PackId,
    /// Upstream URL.
    pub url: String,
    /// Workspace-relative checkout path.
    pub path: String,
    /// Pack flavor.
    pub pack_type: String,
    /// Optional ref spec (branch, tag) — `None` until first `update ref`.
    pub ref_spec: Option<String>,
    /// Last synced SHA — `None` until first `sync`.
    pub last_sync_sha: Option<String>,
    /// Timestamp of the originating `add` event.
    pub added_at: DateTime<Utc>,
    /// Timestamp of the most recent event that touched this pack.
    pub updated_at: DateTime<Utc>,
}

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

    fn ts() -> DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 4, 19, 10, 0, 0).unwrap()
    }

    #[test]
    fn add_roundtrip() {
        let e = Event::Add {
            ts: ts(),
            id: "warp-cfg".into(),
            url: "git@example:warp".into(),
            path: "warp-cfg".into(),
            pack_type: "declarative".into(),
            schema_version: SCHEMA_VERSION.into(),
        };
        let s = serde_json::to_string(&e).unwrap();
        let back: Event = serde_json::from_str(&s).unwrap();
        assert_eq!(e, back);
        assert!(s.contains(r#""op":"add""#));
        assert!(s.contains(r#""type":"declarative""#));
    }

    #[test]
    fn update_roundtrip() {
        let e = Event::Update {
            ts: ts(),
            id: "warp-cfg".into(),
            field: "ref".into(),
            value: serde_json::json!("v0.2.0"),
        };
        let s = serde_json::to_string(&e).unwrap();
        assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
    }

    #[test]
    fn rm_roundtrip() {
        let e = Event::Rm { ts: ts(), id: "warp-cfg".into() };
        let s = serde_json::to_string(&e).unwrap();
        assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
    }

    #[test]
    fn sync_roundtrip() {
        let e = Event::Sync { ts: ts(), id: "warp-cfg".into(), sha: "abc123".into() };
        let s = serde_json::to_string(&e).unwrap();
        assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
    }

    #[test]
    fn unknown_fields_are_accepted() {
        // Forward-compat: a newer writer may add fields the old reader
        // doesn't know about. Parse must still succeed.
        let raw = r#"{"op":"rm","ts":"2026-04-19T10:00:00Z","id":"x","future_field":true}"#;
        let e: Event = serde_json::from_str(raw).unwrap();
        assert_eq!(e.id(), "x");
    }

    #[test]
    fn id_and_ts_accessors() {
        let e = Event::Sync { ts: ts(), id: "a".into(), sha: "s".into() };
        assert_eq!(e.id(), "a");
        assert_eq!(e.ts(), ts());
    }

    #[test]
    fn action_started_roundtrip() {
        let e = Event::ActionStarted {
            ts: ts(),
            pack: "warp".into(),
            action_idx: 3,
            action_name: "symlink".into(),
        };
        let s = serde_json::to_string(&e).unwrap();
        assert!(s.contains(r#""op":"action_started""#));
        assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
    }

    #[test]
    fn action_completed_roundtrip() {
        let e = Event::ActionCompleted {
            ts: ts(),
            pack: "warp".into(),
            action_idx: 1,
            result_summary: "performed_change".into(),
        };
        let s = serde_json::to_string(&e).unwrap();
        assert!(s.contains(r#""op":"action_completed""#));
        assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
    }

    #[test]
    fn action_halted_roundtrip() {
        let e = Event::ActionHalted {
            ts: ts(),
            pack: "warp".into(),
            action_idx: 2,
            action_name: "exec".into(),
            error_summary: "non-zero exit 3".into(),
        };
        let s = serde_json::to_string(&e).unwrap();
        assert!(s.contains(r#""op":"action_halted""#));
        assert_eq!(serde_json::from_str::<Event>(&s).unwrap(), e);
    }

    #[test]
    fn legacy_lowercase_tags_still_parse() {
        // Historical writers used `rename_all = "lowercase"`. snake_case
        // and lowercase are identical for the single-word legacy tags, so
        // old logs must still decode.
        let raw = r#"{"op":"add","ts":"2026-04-19T10:00:00Z","id":"a","url":"u","path":"a","type":"declarative","schema_version":"1"}"#;
        let _: Event = serde_json::from_str(raw).unwrap();
        let raw = r#"{"op":"sync","ts":"2026-04-19T10:00:00Z","id":"a","sha":"deadbeef"}"#;
        let _: Event = serde_json::from_str(raw).unwrap();
    }
}