loopflow 0.12.1

Run steps and flows with coding agents
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
//! W2-151: every `lf` command must resolve the ambient Wave the same way. The
//! bug was never that a resident exports `LF_WAVE_ID=<uuid>` — that is correct.
//! It was that consumers disagreed about how to read it: `status` handled a
//! UUID, `pm show` ignored the env entirely, others silently dropped a hand-set
//! name. This drives the ONE resolver directly across the seven environments,
//! then proves `lf status` and `lf pm show` — the original reproduction — agree
//! end to end from a resident wave's environment.

use std::path::Path;
use std::process::Command;

use loopflow::engine::wave_context::{resolve_managed_wave_name, WaveResolveError};
use loopflow::id::WaveId;
use loopflow::store::sqlite::SqliteStore;
use loopflow::store::{open_store, PmSnapshotRow, StorageConfig};
use loopflow::wave::Wave;

/// The command matrix at the resolver itself: one durable Wave, driven from
/// every ambient environment. Each cell resolves the SAME wave name or returns
/// the SAME classified error — the whole contract in one place.
#[tokio::test]
async fn resolver_matrix_agrees_across_every_environment() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let store = open_store(&StorageConfig::sqlite(tmp.path().join("loopflow.db")))
        .await
        .expect("open store");
    let wave = Wave::new(
        WaveId::new(),
        "product".to_string(),
        tmp.path().join("repo").display().to_string(),
    );
    store.create_wave(&wave).await.expect("register wave");
    let uuid = wave.id().as_str();

    // Wave / Project / Task processes all inherit the same durable UUID — the
    // Project/Task session ids never touch wave identity, so one cell covers
    // all three. UUID first: mapped to its registry name.
    assert_eq!(
        resolve_managed_wave_name(Some(&store), None, Some(uuid))
            .await
            .expect("uuid resolves"),
        "product"
    );

    // Hand-set name: the intentional fallback. `LF_WAVE_ID=product` resolves
    // even though it is not a UUID.
    assert_eq!(
        resolve_managed_wave_name(Some(&store), None, Some("product"))
            .await
            .expect("hand-set name resolves"),
        "product"
    );

    // Explicit `--wave` always wins, even over a (wrong) ambient id.
    assert_eq!(
        resolve_managed_wave_name(Some(&store), Some("product"), Some("something-else"))
            .await
            .expect("explicit wins"),
        "product"
    );

    // Stale identity: a real UUID the registry has never seen. Not silently
    // re-read as a name — a distinct, classified error.
    let stale = WaveId::new();
    assert_eq!(
        resolve_managed_wave_name(Some(&store), None, Some(stale.as_str())).await,
        Err(WaveResolveError::StaleIdentity(stale.as_str().to_string()))
    );

    // No context: neither `--wave` nor `LF_WAVE_ID` (empty counts as absent).
    assert_eq!(
        resolve_managed_wave_name(Some(&store), None, None).await,
        Err(WaveResolveError::NoContext)
    );
    assert_eq!(
        resolve_managed_wave_name(Some(&store), None, Some("   ")).await,
        Err(WaveResolveError::NoContext)
    );

    // An empty `--wave` is an explicit-but-unusable request, not "no context".
    assert_eq!(
        resolve_managed_wave_name(Some(&store), Some("  "), None).await,
        Err(WaveResolveError::EmptyExplicit)
    );

    // An explicit `--wave` naming a wave the registry has never seen is a
    // classified error — never a silent accept. The resolver owns this rule;
    // every consumer surfaces the same classification.
    assert_eq!(
        resolve_managed_wave_name(Some(&store), Some("definitely-unknown"), None).await,
        Err(WaveResolveError::UnknownExplicit(
            "definitely-unknown".to_string()
        ))
    );

    // No registry on this machine + an explicit name → error, not silent
    // accept. A machine with no registry has no valid wave names.
    assert!(matches!(
        resolve_managed_wave_name(None, Some("product"), None).await,
        Err(WaveResolveError::Registry(_))
    ));

    // A hand-set name for a wave with no registry row still resolves to that
    // name — membership is each consumer's concern (PM keys files/snapshots by
    // name; status then reports it has no row). The resolver stays consistent.
    assert_eq!(
        resolve_managed_wave_name(Some(&store), None, Some("ghost"))
            .await
            .expect("unregistered name resolves to itself"),
        "ghost"
    );
}

/// Seed a machine home with one PM-linked wave and a fresh cache-only snapshot,
/// plus a separate repo directory the commands run inside.
fn seed(home: &Path, repo: &Path, wave_name: &str) -> Wave {
    std::fs::create_dir_all(home).expect("home");
    std::fs::create_dir_all(repo).expect("repo");
    let store = SqliteStore::new(&home.join("loopflow.db")).expect("open store");
    let wave = Wave::new(
        WaveId::new(),
        wave_name.to_string(),
        repo.display().to_string(),
    );
    store.create_wave(&wave).expect("register wave");
    let repo_key = std::fs::canonicalize(repo)
        .expect("canonicalize repo")
        .display()
        .to_string();
    store
        .put_pm_snapshot(&PmSnapshotRow {
            repo: repo_key,
            wave: wave_name.to_string(),
            provider: "linear".to_string(),
            initiative: "initiative-1".to_string(),
            synced_at: chrono::Utc::now().timestamp(),
            payload: r#"{"projects":[],"items":[]}"#.to_string(),
        })
        .expect("seed pm snapshot");
    wave
}

fn lf(home: &Path, repo: &Path, args: &[&str], wave_id: Option<&str>) -> std::process::Output {
    let mut command = Command::new(env!("CARGO_BIN_EXE_lf"));
    command
        .args(args)
        .current_dir(repo)
        .env("LF_HOME", home)
        .env_remove("LF_DB_PATH")
        .env_remove("LF_CONTROL_HOME")
        .env_remove("LF_CONTROL_DB_PATH")
        .env_remove("LF_TRACE_ID")
        .env_remove("LF_CHANNEL")
        .env_remove("LF_WAVE_ID");
    if let Some(id) = wave_id {
        command.env("LF_WAVE_ID", id);
    }
    command.output().expect("lf runs")
}

fn wave_field(output: &std::process::Output) -> String {
    assert!(
        output.status.success(),
        "command failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("stdout is JSON");
    // `status` nests the wave; `pm show` names it at the top level.
    json.get("wave")
        .and_then(|wave| wave.get("name").or(Some(wave)))
        .and_then(serde_json::Value::as_str)
        .expect("a wave name")
        .to_string()
}

/// The original reproduction: from a resident wave's environment
/// (`LF_WAVE_ID=<uuid>`, no `--wave`), both `lf pm show` and `lf status` resolve
/// the same wave. The Mac Project Session inherits the identical `LF_WAVE_ID`,
/// so this cell stands for both.
#[test]
fn pm_show_and_status_agree_from_a_resident_uuid() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let home = tmp.path().join("home");
    let repo = tmp.path().join("repo");
    let wave = seed(&home, &repo, "product");
    let uuid = wave.id().as_str();

    let pm = lf(
        &home,
        &repo,
        &["pm", "show", "--no-sync", "--json"],
        Some(uuid),
    );
    let status = lf(&home, &repo, &["status", "--json"], Some(uuid));

    assert_eq!(wave_field(&pm), "product");
    assert_eq!(wave_field(&status), "product");
}

/// Explicit `--wave` beats a wrong ambient id in both commands; a hand-set name
/// resolves; missing and stale contexts are classified errors.
#[test]
fn pm_show_honors_the_shared_resolution_rules() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let home = tmp.path().join("home");
    let repo = tmp.path().join("repo");
    let wave = seed(&home, &repo, "product");
    let uuid = wave.id().as_str();

    // Explicit override wins over a wrong ambient UUID.
    let overridden = lf(
        &home,
        &repo,
        &["pm", "show", "--wave", "product", "--no-sync", "--json"],
        Some(&WaveId::new().to_string()),
    );
    assert_eq!(wave_field(&overridden), "product");

    // Hand-set name resolves the same wave.
    let named = lf(
        &home,
        &repo,
        &["pm", "show", "--no-sync", "--json"],
        Some("product"),
    );
    assert_eq!(wave_field(&named), "product");

    // No context: the classified "pass --wave" error, not a UUID-as-name crash.
    let missing = lf(&home, &repo, &["pm", "show", "--no-sync", "--json"], None);
    assert!(!missing.status.success());
    assert!(
        String::from_utf8_lossy(&missing.stderr).contains("determine wave"),
        "missing-context stderr: {}",
        String::from_utf8_lossy(&missing.stderr)
    );

    // Stale identity: a real UUID with no registry row is a distinct error that
    // names the stale id, never a silent fallback.
    let stale_id = WaveId::new().to_string();
    let stale = lf(
        &home,
        &repo,
        &["pm", "show", "--no-sync", "--json"],
        Some(&stale_id),
    );
    assert!(!stale.status.success());
    let stale_err = String::from_utf8_lossy(&stale.stderr);
    assert!(
        stale_err.contains("stale") && stale_err.contains(&stale_id),
        "stale stderr: {stale_err}"
    );
    let _ = uuid;
}

/// The channel/memory consumers resolve the same wave as `status`/`pm show`.
/// `lf memory show` (via `chat::resolve_target`) reads the resolved wave's
/// MEMORY.md across the whole matrix: UUID, hand-set name, explicit override,
/// stale error, no-context error. Before the shared resolver, a hand-set name
/// silently dropped and a stale UUID was indistinguishable from no wave.
#[test]
fn memory_show_resolves_like_status_across_environments() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let home = tmp.path().join("home");
    let repo = tmp.path().join("repo");
    let wave = seed(&home, &repo, "product");
    let uuid = wave.id().as_str();

    // The wave owns a MEMORY.md under its repo; a serverless `show` reads it.
    let wave_dir = repo.join("wave/product");
    std::fs::create_dir_all(&wave_dir).expect("wave dir");
    std::fs::write(wave_dir.join("MEMORY.md"), "PRODUCT MEMORY\n").expect("seed memory");

    let memory =
        |args: &[&str], id: Option<&str>| -> std::process::Output { lf(&home, &repo, args, id) };
    let stdout = |output: &std::process::Output| -> String {
        assert!(
            output.status.success(),
            "memory show failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8_lossy(&output.stdout).to_string()
    };

    // Durable UUID → its registry name → that wave's MEMORY.md.
    assert_eq!(
        stdout(&memory(&["memory", "show"], Some(uuid))),
        "PRODUCT MEMORY\n"
    );

    // Hand-set name resolves the same wave (the arm that used to drop).
    assert_eq!(
        stdout(&memory(&["memory", "show"], Some("product"))),
        "PRODUCT MEMORY\n"
    );

    // Explicit `--wave` beats a wrong ambient UUID.
    assert_eq!(
        stdout(&memory(
            &["memory", "show", "--wave", "product"],
            Some(&WaveId::new().to_string()),
        )),
        "PRODUCT MEMORY\n"
    );

    // Stale UUID: a loud, classified error naming the id — not a silent empty
    // read.
    let stale_id = WaveId::new().to_string();
    let stale = memory(&["memory", "show"], Some(&stale_id));
    assert!(!stale.status.success());
    let stale_err = String::from_utf8_lossy(&stale.stderr);
    assert!(
        stale_err.contains("stale") && stale_err.contains(&stale_id),
        "stale stderr: {stale_err}"
    );

    // No context: a read is not a publish, so it errors and names the fix.
    let missing = memory(&["memory", "show"], None);
    assert!(!missing.status.success());
    assert!(
        String::from_utf8_lossy(&missing.stderr).contains("--wave"),
        "missing-context stderr: {}",
        String::from_utf8_lossy(&missing.stderr)
    );
}

/// W2-240: an explicit `--wave` naming an unknown wave is rejected with the
/// same classified error from every consumer — never silently accepted (the
/// memory bug), never misdirected to a sync command (the PM bug), never given
/// a generic "not found" (the status bug). The error names the wave and the
/// safe next action. A valid ambient does not rescue an unknown explicit:
/// explicit always wins.
#[test]
fn unknown_explicit_wave_is_rejected_identically_by_every_consumer() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let home = tmp.path().join("home");
    let repo = tmp.path().join("repo");
    let wave = seed(&home, &repo, "product");
    let uuid = wave.id().as_str();

    let assert_rejected = |output: std::process::Output, label: &str| {
        assert!(
            !output.status.success(),
            "{label}: unknown explicit wave should exit non-zero"
        );
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains("definitely-unknown"),
            "{label}: stderr should name the unknown wave: {stderr}"
        );
        assert!(
            stderr.contains("is not registered on this machine"),
            "{label}: stderr should classify as unregistered: {stderr}"
        );
    };

    // No ambient: each consumer rejects the unknown explicit on its own.
    assert_rejected(
        lf(
            &home,
            &repo,
            &["memory", "show", "--wave", "definitely-unknown"],
            None,
        ),
        "memory show (no ambient)",
    );
    assert_rejected(
        lf(
            &home,
            &repo,
            &["status", "--wave", "definitely-unknown"],
            None,
        ),
        "status (no ambient)",
    );
    assert_rejected(
        lf(
            &home,
            &repo,
            &["pm", "show", "--wave", "definitely-unknown", "--no-sync"],
            None,
        ),
        "pm show (no ambient)",
    );

    // Valid ambient does not rescue an unknown explicit: explicit wins.
    assert_rejected(
        lf(
            &home,
            &repo,
            &["memory", "show", "--wave", "definitely-unknown"],
            Some(uuid),
        ),
        "memory show (with ambient)",
    );
    assert_rejected(
        lf(
            &home,
            &repo,
            &["status", "--wave", "definitely-unknown"],
            Some(uuid),
        ),
        "status (with ambient)",
    );
    assert_rejected(
        lf(
            &home,
            &repo,
            &["pm", "show", "--wave", "definitely-unknown", "--no-sync"],
            Some(uuid),
        ),
        "pm show (with ambient)",
    );
}