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
//! `memstead anchors` — read provenance anchors (E3a).
//!
//! Two read modes, no mutation:
//!
//! * **By entity.** `memstead anchors <id>` lists the entity's stored
//! anchors plus their class/grain composition.
//! * **By artifact (reverse lookup).** `memstead anchors --artifact <path>`
//! lists every `(entity, anchor)` across all mems whose anchor
//! references that path. This is the query the rebuilt
//! check-realization plugin hook consumes: given the file an agent just
//! edited, which entities anchored to it. `tree`-grain anchors match the
//! path and anything beneath the tree.
use clap::Parser;
use memstead_base::EntityId;
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::{CliContext, CliEngine};
/// Read provenance anchors by entity or by referenced artifact path.
#[derive(Parser, Debug)]
pub struct Args {
/// Entity ID (e.g. `specs--my-entity`). Required unless `--artifact`
/// is given.
pub id: Option<String>,
/// Reverse lookup: list every entity whose anchor references this
/// artifact path. Mutually exclusive with a positional entity id.
#[arg(long = "artifact", value_name = "PATH", conflicts_with = "id")]
pub artifact: Option<String>,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
if args.id.is_none() && args.artifact.is_none() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
"pass an entity id or `--artifact <path>`",
)
.into());
}
// Collect the anchor rows off whichever engine backs the workspace.
// Both variants expose the same read surface. `state` carries the live
// resolution (present only for a by-entity lookup on a path-medium mem;
// `None` for the reverse `--artifact` lookup, which spans mems).
let (rows, unreadable): (Vec<AnchorRow>, Vec<SidecarCondition>) = match ctx.cli_engine()? {
#[cfg(feature = "mem-repo")]
CliEngine::MemRepo(engine) => {
(collect(&engine, &args), unreadable_sidecars(&engine, &args))
}
CliEngine::Filesystem(engine) => {
(collect(&engine, &args), unreadable_sidecars(&engine, &args))
}
};
// By entity: the one mem's sidecar is the whole answer, and an
// unreadable one is a refusal, not "no anchors". By artifact: the
// lookup spans mems, so the readable ones still answer and the
// unreadable ones ride along as conditions.
if args.id.is_some()
&& let Some(c) = unreadable.first()
{
return Err(CliError::new(
ExitKind::Validation,
"ANCHORS_SIDECAR_UNREADABLE",
format!(
"mem `{}`: the anchors sidecar could not be read ({}); the entity's anchors \
are unknown, not absent",
c.mem, c.reason
),
)
.with_details(serde_json::json!({ "mem": c.mem, "reason": c.reason }))
.into());
}
let now = memstead_base::engine::mutation::iso_now();
if ctx.json {
let anchors_json: Vec<serde_json::Value> = rows
.iter()
.map(|(id, a, state, observed_at)| {
let mut v = serde_json::to_value(a).unwrap_or(serde_json::Value::Null);
if let Some(obj) = v.as_object_mut() {
obj.insert("entity_id".into(), serde_json::json!(id));
if let Some(s) = state {
obj.insert("state".into(), serde_json::json!(s.as_wire()));
}
if let Some(at) = observed_at {
obj.insert("observed_at".into(), serde_json::json!(at));
if let Some(days) = memstead_base::anchor::days_between(at, &now) {
obj.insert("unobserved_for_days".into(), serde_json::json!(days));
}
}
}
v
})
.collect();
let anchors_only: Vec<memstead_base::anchor::Anchor> =
rows.iter().map(|(_, a, _, _)| a.clone()).collect();
let composition = memstead_base::anchor::compose_entity_anchors(&anchors_only);
print_json(&serde_json::json!({
"count": rows.len(),
"anchors": anchors_json,
"composition": composition,
// Mems whose sidecar could not be read: their rows are unknown,
// not absent, and `count` does not cover them.
"sidecar_unreadable": unreadable
.iter()
.map(|c| serde_json::json!({
"code": "ANCHORS_SIDECAR_UNREADABLE",
"mem": c.mem,
"reason": c.reason,
}))
.collect::<Vec<_>>(),
}))?;
} else if rows.is_empty() && unreadable.is_empty() {
let subject = args
.artifact
.as_deref()
.map(|p| format!("artifact `{p}`"))
.or_else(|| args.id.as_deref().map(|i| format!("entity `{i}`")))
.unwrap_or_default();
print_markdown(&format!("# Anchors\n\nNo anchors for {subject}."));
} else {
let mut body = format!("# Anchors ({})\n", rows.len());
for c in &unreadable {
body.push_str(&format!(
"\n> **ANCHORS_SIDECAR_UNREADABLE** — mem `{}`: {}. Its rows are unknown, not \
absent; the count above does not cover it.\n",
c.mem, c.reason
));
}
for (id, a, state, observed_at) in &rows {
let hash = a.hash.as_deref().unwrap_or("-");
let state_str = state
.map(|s| format!(", state: {}", s.as_wire()))
.unwrap_or_default();
// A recorded observation's age travels with its state: a url
// row's state is exactly as current as the observation it rests on.
let age_str = observed_at
.as_deref()
.map(|at| {
let days = memstead_base::anchor::days_between(at, &now).unwrap_or(0);
format!(", observed {at}, unobserved for {days} day(s)")
})
.unwrap_or_default();
body.push_str(&format!(
"\n- `{id}` — {} {} `{}` (hash: {hash}{state_str}{age_str})",
a.class.as_wire(),
a.grain.as_wire(),
a.artifact,
));
}
print_markdown(&body);
}
Ok(())
}
/// A mem whose anchors sidecar could not be read this pass.
struct SidecarCondition {
mem: String,
reason: String,
}
/// The unreadable sidecars the requested lookup touches: the one mem for a
/// by-entity read, every mounted mem for the reverse lookup.
fn unreadable_sidecars(engine: &memstead_base::Engine, args: &Args) -> Vec<SidecarCondition> {
let mems: Vec<String> = if let Some(id) = args.id.as_deref() {
vec![EntityId::canonical(id).mem().to_string()]
} else {
engine.mem_names().iter().map(|m| m.to_string()).collect()
};
mems.into_iter()
.filter_map(|mem| {
engine
.anchors_sidecar_error(&mem)
.map(|reason| SidecarCondition { mem, reason })
})
.collect()
}
/// One anchor row: `(entity_id, anchor, live_state, observed_at)` — the
/// last element is present for a row whose state rests on a recorded
/// observation (a `url` row) rather than a live one.
type AnchorRow = (
String,
memstead_base::anchor::Anchor,
Option<memstead_base::anchor::AnchorState>,
Option<String>,
);
/// Gather anchor rows from an engine per the requested mode. The by-entity
/// lookup carries the live resolution state; the reverse `--artifact` lookup
/// spans mems and carries none.
fn collect(engine: &memstead_base::Engine, args: &Args) -> Vec<AnchorRow> {
if let Some(path) = args.artifact.as_deref() {
engine
.anchors_referencing_artifact(path)
.into_iter()
.map(|(id, a)| (id.to_string(), a, None, None))
.collect()
} else if let Some(id) = args.id.as_deref() {
let eid = EntityId::canonical(id);
engine
.entity_anchors_resolved(&eid)
.into_iter()
.map(|r| (eid.to_string(), r.anchor, r.state, r.observed_at))
.collect()
} else {
Vec::new()
}
}