torc 0.24.2

Workflow management system
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
//! Integration tests for `torc --standalone` / `torc -s`.
//!
//! These tests exercise the ephemeral-server path that `main.rs` takes when the
//! user passes `-s`: spawning a `torc-server` subprocess bound to 127.0.0.1, routing
//! the client against it, and tearing it down on exit. Unlike most other tests in
//! this crate we do NOT rely on the shared `start_server` fixture — the whole point
//! here is to verify that `torc -s ...` brings up and shuts down its own server.
//!
//! These tests run the CLI end-to-end as a subprocess, so they require the
//! debug-built `torc` and `torc-server` binaries. `ensure_test_binaries_built()`
//! takes care of that once per test binary.

use std::process::Command;
use std::time::Duration;

use tempfile::TempDir;

#[path = "common.rs"]
mod common;

use common::{
    ensure_test_binaries_built, run_torc_standalone, run_torc_standalone_ok, torc_binary_path,
    torc_server_binary_path,
};

#[test]
fn standalone_exec_creates_and_runs_workflow() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("torc_output").join("torc.db");

    // Single-command exec is the canonical `torc -s` smoke test: spawn the server,
    // synthesize a one-job workflow, run it locally, then tear the server down.
    let out = run_torc_standalone_ok(work.path(), &db, &["exec", "-c", "echo hello-standalone"]);

    let stdout = String::from_utf8_lossy(&out.stdout);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("Started standalone torc-server"),
        "stderr should log server startup; got:\n{}",
        stderr
    );
    assert!(
        stdout.contains("Created workflow"),
        "stdout should announce workflow creation; got:\n{}",
        stdout
    );
    assert!(db.exists(), "database at {:?} was not created", db);
}

#[test]
fn standalone_persists_workflow_across_invocations() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("torc.db");

    // First invocation creates and runs the workflow.
    let first = run_torc_standalone_ok(work.path(), &db, &["exec", "-c", "echo persist-me"]);
    assert!(
        String::from_utf8_lossy(&first.stdout).contains("Created workflow"),
        "first invocation should create a workflow"
    );

    // Second invocation — brand new server, same DB — must be able to see the workflow.
    // Use `-f json` so we can machine-parse the response.
    let second = run_torc_standalone_ok(work.path(), &db, &["-f", "json", "workflows", "list"]);
    let stdout = String::from_utf8_lossy(&second.stdout).to_string();
    let parsed: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("workflows list JSON parse failed: {}\n---\n{}", e, stdout));
    // `list_workflows` returns `{"workflows": [...]}`.
    let items = parsed
        .get("workflows")
        .and_then(|v| v.as_array())
        .unwrap_or_else(|| panic!("expected workflows[] in list response: {}", stdout));
    assert!(
        !items.is_empty(),
        "expected ≥1 workflow in standalone DB after exec run; got {}",
        stdout
    );
}

#[test]
fn standalone_invalid_server_bin_fails_cleanly() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("torc.db");

    // Use a bogus server binary path. We can't call run_torc_standalone() because
    // it wires up the real binary — build the command manually here instead.
    let out = Command::new(torc_binary_path())
        .current_dir(work.path())
        .args([
            "-s",
            "--torc-server-bin",
            "/nonexistent/torc-server-bogus",
            "--db",
            db.to_str().unwrap(),
            "exec",
            "-c",
            "echo no-server",
        ])
        .env_remove("TORC_API_URL")
        .env("RUST_LOG", "warn")
        .output()
        .expect("failed to spawn torc");

    assert!(
        !out.status.success(),
        "bogus --torc-server-bin should fail; stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("Error starting standalone torc-server")
            || stderr.contains("failed to spawn"),
        "stderr should explain the failure; got:\n{}",
        stderr,
    );
}

#[test]
fn standalone_creates_missing_db_parent_directory() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    // Intentionally nested path — the CLI is responsible for mkdir -p'ing this.
    let db = work.path().join("nested").join("subdir").join("torc.db");
    assert!(!db.parent().unwrap().exists());

    run_torc_standalone_ok(work.path(), &db, &["exec", "-c", "echo nested-ok"]);

    assert!(
        db.parent().unwrap().exists(),
        "standalone should have created parent dir for --db path"
    );
    assert!(db.exists(), "db file should exist at {:?}", db);
}

#[test]
fn standalone_default_db_is_torc_output_torc_db() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");

    // Omit --db to exercise the default (./torc_output/torc.db, relative to CWD).
    let server_bin = torc_server_binary_path();
    let out = Command::new(torc_binary_path())
        .current_dir(work.path())
        .args([
            "-s",
            "--torc-server-bin",
            server_bin.to_str().unwrap(),
            "exec",
            "-c",
            "echo default-db",
        ])
        .env_remove("TORC_API_URL")
        .env("RUST_LOG", "warn")
        .output()
        .expect("failed to spawn torc");

    assert!(
        out.status.success(),
        "default-db exec should succeed. stderr:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let default_db = work.path().join("torc_output").join("torc.db");
    assert!(
        default_db.exists(),
        "expected default DB at {:?} after `torc -s exec`",
        default_db
    );
}

#[test]
fn standalone_no_op_for_local_command_prints_notice() {
    ensure_test_binaries_built();

    // PlotResources is one of the commands that doesn't need a server. `main.rs`
    // treats `--standalone` as a no-op for these and prints a warning instead of
    // launching a server.
    let work = TempDir::new().expect("tempdir");
    let fake_metrics_db = work.path().join("no-such.db");

    let out = Command::new(torc_binary_path())
        .current_dir(work.path())
        .args([
            "-s",
            "--torc-server-bin",
            "/definitely/not/a/real/path",
            "plot-resources",
            fake_metrics_db.to_str().unwrap(),
        ])
        .env_remove("TORC_API_URL")
        .env("RUST_LOG", "warn")
        .output()
        .expect("failed to spawn torc");

    // The command will fail (no metrics db), but the `--standalone` handling must
    // not have attempted to launch the bogus server binary. Specifically, we
    // should see the "has no effect" notice on stderr and *not* see a spawn error.
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("--standalone has no effect"),
        "expected '--standalone has no effect' notice; stderr:\n{}",
        stderr
    );
    assert!(
        !stderr.contains("Error starting standalone torc-server"),
        "should not have attempted to launch the bogus server; stderr:\n{}",
        stderr
    );
}

#[cfg(unix)]
#[test]
fn standalone_server_shuts_down_after_command_exits() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("torc.db");

    run_torc_standalone_ok(work.path(), &db, &["exec", "-c", "echo shutdown-test"]);

    // Give the OS a moment to reap the child.
    std::thread::sleep(Duration::from_millis(500));

    // The unique db path in the command line is our fingerprint for the server
    // subprocess — find any `torc-server` process still holding it open.
    let ps = Command::new("ps")
        .args(["-Ao", "args="])
        .output()
        .expect("ps failed");
    let listing = String::from_utf8_lossy(&ps.stdout);
    let db_str = db.to_string_lossy();
    let lingering: Vec<&str> = listing
        .lines()
        .filter(|l| l.contains(&*db_str) && l.contains("torc-server"))
        .collect();
    assert!(
        lingering.is_empty(),
        "expected no torc-server subprocess after `torc -s exec` exits; found: {:#?}",
        lingering
    );
}

#[cfg(unix)]
#[test]
fn standalone_server_shuts_down_when_client_exits_via_process_exit() {
    // Failure paths in the CLI frequently call std::process::exit, which bypasses
    // destructors. Without the parent-death pipe, the standalone subprocess would
    // be orphaned. This test fails the client *after* the server has started
    // (`exec` with no `-c` commands rejects via process::exit(2)) and verifies the
    // server still shuts down.
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("process_exit.db");

    let out = run_torc_standalone(work.path(), &db, &["exec"]);
    assert!(
        !out.status.success(),
        "`torc -s exec` with no commands should fail. stderr:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("Started standalone torc-server"),
        "the server must have been started before the failure for this test to be meaningful"
    );

    // Give the server a moment to see stdin EOF and drain connections.
    std::thread::sleep(Duration::from_secs(2));

    let ps = Command::new("ps")
        .args(["-Ao", "args="])
        .output()
        .expect("ps failed");
    let listing = String::from_utf8_lossy(&ps.stdout);
    let db_str = db.to_string_lossy();
    let lingering: Vec<&str> = listing
        .lines()
        .filter(|l| l.contains(&*db_str) && l.contains("torc-server"))
        .collect();
    assert!(
        lingering.is_empty(),
        "torc-server subprocess leaked after client exited via process::exit; found: {:#?}",
        lingering
    );
}

#[test]
fn non_standalone_does_not_start_server() {
    // Sanity check: without -s, the client should *not* print the standalone
    // startup line. Guards against accidentally wiring standalone as the default.
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");

    // Point at an obviously-unreachable URL so the command fails fast rather
    // than waiting for a network timeout against some other process.
    let out = Command::new(torc_binary_path())
        .current_dir(work.path())
        .args([
            "--url",
            "http://127.0.0.1:1/torc-service/v1",
            "workflows",
            "list",
        ])
        .env_remove("TORC_API_URL")
        .env("RUST_LOG", "warn")
        .output()
        .expect("failed to spawn torc");

    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("Started standalone torc-server"),
        "non-standalone invocation must not start a server; stderr:\n{}",
        stderr
    );
    // The command itself is expected to fail (unreachable URL); we only care that
    // the standalone code path was not triggered.
}

/// Helper: run `torc -s --in-memory --db <db> <args>` and return the output.
/// The standalone helper hard-codes the flag order, so we build the command
/// manually here to inject `--in-memory` ahead of the subcommand.
#[cfg(unix)]
fn run_torc_in_memory(
    work_dir: &std::path::Path,
    db_path: &std::path::Path,
    extra_args: &[&str],
    args: &[&str],
) -> std::process::Output {
    let server_bin = torc_server_binary_path();
    assert!(
        server_bin.exists(),
        "torc-server binary missing at {:?}",
        server_bin
    );

    let target_debug = std::env::current_dir().expect("cwd").join("target/debug");
    let existing = std::env::var_os("PATH").unwrap_or_default();
    let mut entries: Vec<std::path::PathBuf> = vec![target_debug];
    entries.extend(std::env::split_paths(&existing));
    let path_var = std::env::join_paths(entries).expect("join PATH entries");

    let mut cmd = Command::new(torc_binary_path());
    cmd.current_dir(work_dir)
        .arg("-s")
        .arg("--in-memory")
        .args(["--torc-server-bin", server_bin.to_str().unwrap()])
        .args(["--db", db_path.to_str().unwrap()])
        .args(extra_args)
        .args(args)
        .env_remove("TORC_API_URL")
        .env("RUST_LOG", "warn")
        .env("PATH", path_var);
    cmd.output().expect("failed to spawn torc")
}

#[cfg(unix)]
#[test]
fn standalone_in_memory_snapshot_is_queryable() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("snap.db");

    // `--in-memory` runs the server entirely in RAM, then snapshots to `--db`
    // right before shutdown. After the command returns, the snapshot file
    // must exist and contain the workflow we just created.
    let out = run_torc_in_memory(work.path(), &db, &[], &["exec", "-c", "echo in-mem-ok"]);
    assert!(
        out.status.success(),
        "torc -s --in-memory exec failed (status {:?}):\n--- stdout ---\n{}\n--- stderr ---\n{}",
        out.status.code(),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    assert!(
        db.exists(),
        "snapshot DB at {:?} should exist after --in-memory exec returns",
        db
    );
    // No timeout warning — drop+drain logic should land the final snapshot
    // synchronously.
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("timed out waiting for final snapshot"),
        "final snapshot timed out unexpectedly; stderr:\n{}",
        stderr
    );

    // Verify the workflow is readable from the snapshot via a fresh
    // standalone invocation (now without --in-memory; just on-disk against
    // the snapshot file).
    let listed = run_torc_standalone_ok(work.path(), &db, &["-f", "json", "workflows", "list"]);
    let stdout = String::from_utf8_lossy(&listed.stdout).to_string();
    let parsed: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("workflows list JSON parse failed: {}\n---\n{}", e, stdout));
    let items = parsed
        .get("workflows")
        .and_then(|v| v.as_array())
        .unwrap_or_else(|| panic!("expected workflows[] in list response: {}", stdout));
    assert!(
        !items.is_empty(),
        "expected ≥1 workflow in --in-memory snapshot DB; got {}",
        stdout
    );
}

#[cfg(unix)]
#[test]
fn standalone_in_memory_periodic_snapshot_lands_before_exit() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("periodic-snap.db");

    // Run a workflow that sleeps long enough for at least one periodic
    // snapshot to fire mid-run, then verify the final snapshot is intact
    // and contains the completed job. 1.5 s vs the 1 s interval gives one
    // guaranteed tick without paying for a full multi-second sleep on CI.
    let out = run_torc_in_memory(
        work.path(),
        &db,
        &["--snapshot-interval-seconds", "1"],
        &["exec", "-c", "sleep 1.5 && echo periodic-ok"],
    );
    assert!(
        out.status.success(),
        "torc -s --in-memory --snapshot-interval-seconds 1 exec failed:\n--- stderr ---\n{}",
        String::from_utf8_lossy(&out.stderr),
    );
    assert!(db.exists(), "snapshot DB at {:?} should exist", db);

    let listed = run_torc_standalone_ok(work.path(), &db, &["-f", "json", "workflows", "list"]);
    let stdout = String::from_utf8_lossy(&listed.stdout);
    assert!(
        stdout.contains("\"workflows\""),
        "expected workflows list response; got {}",
        stdout
    );
}

#[cfg(unix)]
#[test]
fn standalone_in_memory_rejected_for_read_only_command() {
    ensure_test_binaries_built();

    let work = TempDir::new().expect("tempdir");
    let db = work.path().join("readonly.db");

    // `--in-memory` should be refused for commands that don't create
    // workflow state — otherwise the empty in-memory DB would snapshot
    // over an existing torc.db and destroy prior data.
    let out = run_torc_in_memory(work.path(), &db, &[], &["workflows", "list"]);
    assert!(
        !out.status.success(),
        "--in-memory + workflows list should fail; stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("--in-memory is only supported with"),
        "stderr should explain the restriction; got:\n{}",
        stderr
    );
    assert!(
        !db.exists(),
        "rejected --in-memory must not create the snapshot file; got {:?}",
        db
    );
}