feat(gui): add service Stop buttons and per-row profile deletion master
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Fri, 4 Sep 2026 17:01:58 +0000 (20:01 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Fri, 4 Sep 2026 17:01:58 +0000 (20:01 +0300)
- Overview panel: each service row (chat :8080, memory :8081) gains
  a Stop button running systemctl --user stop, to free the service's
  RAM/VRAM on demand; disabled while the service is down or a job
  runs. A stale Profiles/.served after stopping memory is harmless —
  Tools/memory use restarts an inactive unit regardless.
- Every profile row (plain, unlocked, locked) gains a Delete button:
  a confirmation dialog states exactly what is lost, then runs
  ./alyverkko delete P as a job; refusals stream into the log pane.
- New facade command ./alyverkko delete P (Tools/memory delete):
  refuses while the memory server serves P or a chat on P is running
  (live .lock PID + ACTIVE==P), clears a stale ACTIVE, unmounts
  encrypted profiles via Tools/vault lock before deleting the vault
  ciphertext; honors ALYVERKKO_PROFILES_ROOT (test seam).
- Panel jobs now pass ALYVERKKO_PROFILES_ROOT to the facade
  subprocess so the panel and its jobs agree on the profiles root.
- Tests: ProfileDeleteTest (plain/locked/mounted-vault deletion,
  in-use refusals, stale-ACTIVE cleanup; real gocryptfs for the
  mounted case), OverviewGuiTest covers Stop enablement and the
  Cancel-then-confirm delete flow. AGENTS.org and
  Documentation/index.org updated.

Also removes Profiles/Assistant/prompt.txt (profile deleted).

AGENTS.org
Documentation/index.org
Profiles/Assistant/prompt.txt [deleted file]
Tools/memory
alyverkko
src/main/java/eu/svjatoslav/alyverkko/assistant/gui/Overview.java
src/test/java/eu/svjatoslav/alyverkko/assistant/ProfileDeleteTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/alyverkko/assistant/gui/OverviewGuiTest.java

index 70428da..4869a58 100644 (file)
@@ -66,8 +66,14 @@ llama-server REST endpoint.
     ~New profile…~ / ~New encrypted profile…~ creation
     dialogs, RAM (/proc/meminfo), VRAM (amdgpu sysfs
     ~mem_info_vram_*~) and Swap (/proc/meminfo SwapTotal/Free) fill
     ~New profile…~ / ~New encrypted profile…~ creation
     dialogs, RAM (/proc/meminfo), VRAM (amdgpu sysfs
     ~mem_info_vram_*~) and Swap (/proc/meminfo SwapTotal/Free) fill
-    bars, and chat/memory service dots —
-    refreshed every 2 s by a scheduler thread. Buttons spawn the
+    bars, and chat/memory service dots, each with a ~Stop~ button
+    (systemctl stop, to free the service's RAM/VRAM on demand;
+    disabled while the service is down or a job runs) —
+    refreshed every 2 s by a scheduler thread. Every row also has a
+    ~Delete~ button: a confirmation dialog first, then ~./alyverkko
+    delete P~ as a job (refuses while the memory server serves P or a
+    chat on P is running; encrypted rows delete the vault ciphertext,
+    unmounting first when open). Buttons spawn the
     facade as a subprocess (output streams into the log pane); one
     job at a time, and the window refuses to close mid-job (a dying
     JVM would SIGPIPE the facade shell and kill e.g. a training run)
     facade as a subprocess (output streams into the log pane); one
     job at a time, and the window refuses to close mid-job (a dying
     JVM would SIGPIPE the facade shell and kill e.g. a training run)
@@ -189,6 +195,9 @@ llama-server REST endpoint.
   ~memory-process~ / ~memory-train~ — each takes
   a profile name, ~status~, ~stop~) — a thin facade over
   ~Tools/memory~; ~chat~ builds and launches the jar itself.
   ~memory-process~ / ~memory-train~ — each takes
   a profile name, ~status~, ~stop~) — a thin facade over
   ~Tools/memory~; ~chat~ builds and launches the jar itself.
+  ~delete P~ permanently deletes a profile (plain or encrypted —
+  no confirmation on the terminal, the GUI asks first; refuses
+  while P is served by the memory server or has a chat running).
 - ~status.sh~ :: read-only status of the ~llama-chat~ /
   ~alyverkko-memory~ services (state, pid, RSS, uptime, health,
   served model, chat slot occupancy), the active profile, plus
 - ~status.sh~ :: read-only status of the ~llama-chat~ /
   ~alyverkko-memory~ services (state, pid, RSS, uptime, health,
   served model, chat slot occupancy), the active profile, plus
@@ -242,8 +251,9 @@ chosen profile becomes active: the jar runs ~Tools/memory use~, which
 points the memory service at the profile's model.
 ~gui~ opens the profiles overview panel: per-persona
 Chat/Process/Train buttons (enabled only when the persona has
 points the memory service at the profile's model.
 ~gui~ opens the profiles overview panel: per-persona
 Chat/Process/Train buttons (enabled only when the persona has
-unprocessed transcripts / fresh simulations), RAM and VRAM fill bars
-and the LLM service states, refreshing every 2 s.
+unprocessed transcripts / fresh simulations) plus a Delete button
+(confirmation dialog; refuses while P is in use), RAM and VRAM fill
+bars and the LLM service states, refreshing every 2 s.
 ~memory-process~ / ~memory-train~ each require a
 profile name and run the memory pipeline phases for that profile,
 ~status~ / ~stop~ inspect and stop the services. ~logs P~ lists P's
 ~memory-process~ / ~memory-train~ each require a
 profile name and run the memory pipeline phases for that profile,
 ~status~ / ~stop~ inspect and stop the services. ~logs P~ lists P's
index f6b4188..9b087f1 100644 (file)
@@ -41,8 +41,13 @@ runs at a time (a lock file enforces it).
 every persona listed with Chat, Process and Train buttons (Process is
 enabled only when the persona has unprocessed chat transcripts, Train
 only when it has fresh simulated conversations, locked encrypted
 every persona listed with Chat, Process and Train buttons (Process is
 enabled only when the persona has unprocessed chat transcripts, Train
 only when it has fresh simulated conversations, locked encrypted
-profiles are shown but not actionable), a RAM and a VRAM fill bar, and
-the chat/memory LLM service states — all refreshed every two seconds.
+profiles are shown but not actionable) plus a Delete button that asks
+for confirmation and then permanently deletes the profile (refusing
+while the memory server serves it or a chat on it is running), a RAM
+and a VRAM fill bar, and
+the chat/memory LLM service states, each with a Stop button that
+stops the service manually when its memory is needed elsewhere — all
+refreshed every two seconds.
 Buttons run the corresponding pipeline commands with their output
 streamed into a log pane at the bottom.
 
 Buttons run the corresponding pipeline commands with their output
 streamed into a log pane at the bottom.
 
@@ -111,6 +116,7 @@ Then, from the project root:
 ./alyverkko chat --no-voice --persona Assistant  # window, but no Piper
 ./alyverkko unlock Secret             # mount an encrypted profile's vault
 ./alyverkko lock                      # lock every unlocked vault
 ./alyverkko chat --no-voice --persona Assistant  # window, but no Piper
 ./alyverkko unlock Secret             # mount an encrypted profile's vault
 ./alyverkko lock                      # lock every unlocked vault
+./alyverkko delete P                  # permanently delete profile P
 #+end_example
 
 - Every session of profile =P= is appended to
 #+end_example
 
 - Every session of profile =P= is appended to
diff --git a/Profiles/Assistant/prompt.txt b/Profiles/Assistant/prompt.txt
deleted file mode 100644 (file)
index a72b3a1..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-You are digital AI assistant.
-
-You are talkative, creative and curious minded.
-
-But when answering question, don't be encyclopedic and verbose.
-Instead speak naturally and answer with one or twe sentences at most. Speak casually as if a buddy or friend would reply:
-high level and short answer. Go into details only when specifically asked.
-
-Everything you write is shown on screen as text, silently. To say
-something out loud, write it in a SPEAK line, for example
-SPEAK: Hey, good to hear from you.
-In casual conversation, answer with one or two SPEAK lines, then end
-your turn with a YIELD line.
-
-Spoken lines go to a text-to-speech engine, so keep them natural
-speech. No structural formatting like bullet points or tables.
-No asterisks, quotes, brackets or hyphens in spoken lines.
-
-Begin every sentence with new line. Output should feel like natural speech.
-
-When you write something long or formatted, like code or a detailed
-summary, write it as normal text and add a SPEAK line with a short
-spoken comment. What you write and what you say can differ.
index 63ff702..8b63914 100755 (executable)
@@ -28,6 +28,9 @@
 #                         (memory-f16.gguf + memory-Q8_0.gguf) and
 #                         restart the memory server onto it when P is
 #                         the active profile (everything down)
 #                         (memory-f16.gguf + memory-Q8_0.gguf) and
 #                         restart the memory server onto it when P is
 #                         the active profile (everything down)
+#   Tools/memory delete P   permanently delete P (plain or encrypted);
+#                         refuses while the memory server serves P or a
+#                         chat on P is running
 #
 # A full memory update for profile P is: process P -> train P -> chat
 set -euo pipefail
 #
 # A full memory update for profile P is: process P -> train P -> chat
 set -euo pipefail
@@ -203,6 +206,59 @@ case "${1:-}" in
         echo "[memory] train + package done. Back to normal: Tools/memory chat"
         ;;
 
         echo "[memory] train + package done. Back to normal: Tools/memory chat"
         ;;
 
+    delete)
+        # Permanently delete a profile (plain or encrypted). Refuses
+        # while the profile is in use: the memory server serving its
+        # GGUF or a chat running on it. Profiles root override (tests):
+        # ALYVERKKO_PROFILES_ROOT, same contract as Tools/vault.
+        P="${2:-}"
+        case "$P" in
+            ""|.|..|*/*|*\\*)
+                echo "[memory] invalid profile name: '$P'" >&2
+                exit 2 ;;
+        esac
+        PROFILES_ROOT="${ALYVERKKO_PROFILES_ROOT:-$PROJECT_ROOT/Profiles}"
+        if [ ! -d "$PROFILES_ROOT/$P" ] \
+                && [ ! -d "$PROFILES_ROOT/$P.vault" ]; then
+            echo "[memory] no such profile: '$P'" >&2
+            exit 2
+        fi
+        if sys is-active --quiet "$MEM_UNIT" \
+                && [ "$(cat "$PROFILES_ROOT/.served" 2>/dev/null)" = "$P" ]; then
+            echo "[memory] '$P' is being served by $MEM_UNIT — stop it" \
+                "first (panel Stop button or ./alyverkko stop)" >&2
+            exit 2
+        fi
+        LOCK_PID="$(cat "$PROFILES_ROOT/.lock" 2>/dev/null || true)"
+        if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null \
+                && [ "$(cat "$PROFILES_ROOT/ACTIVE" 2>/dev/null)" = "$P" ]; then
+            echo "[memory] a chat on '$P' is running — close it first" >&2
+            exit 2
+        fi
+        # A stale ACTIVE pointing at the deleted profile would break
+        # the next "use" — drop it.
+        if [ "$(cat "$PROFILES_ROOT/ACTIVE" 2>/dev/null)" = "$P" ]; then
+            rm -f "$PROFILES_ROOT/ACTIVE"
+        fi
+        if [ -f "$PROFILES_ROOT/$P.vault/gocryptfs.conf" ]; then
+            # Encrypted: unmount first when mounted (Tools/vault lock
+            # owns the retry dance), then the ciphertext tree and the
+            # now-empty mount dir.
+            if mountpoint -q "$PROFILES_ROOT/$P"; then
+                ALYVERKKO_PROFILES_ROOT="$PROFILES_ROOT" \
+                    "$PROJECT_ROOT/Tools/vault" lock "$P"
+            fi
+            rm -rf "$PROFILES_ROOT/$P.vault"
+            rmdir "$PROFILES_ROOT/$P" 2>/dev/null || true
+            echo "[memory] deleted encrypted profile '$P'" \
+                "(vault ciphertext + mount)"
+        else
+            rm -rf "$PROFILES_ROOT/$P"
+            echo "[memory] deleted profile '$P'" \
+                "(prompt, conversations, simulations, models)"
+        fi
+        ;;
+
     *)
         cat <<'EOF'
 Usage: Tools/memory <command>
     *)
         cat <<'EOF'
 Usage: Tools/memory <command>
@@ -215,6 +271,8 @@ Usage: Tools/memory <command>
   process P  P's transcripts + facts -> Profiles/P/train.jsonl  (chat model up)
   train P    train.jsonl -> new HF model dir under Profiles/P/, packaged
              to GGUF; memory server onto it if P is active    (all down)
   process P  P's transcripts + facts -> Profiles/P/train.jsonl  (chat model up)
   train P    train.jsonl -> new HF model dir under Profiles/P/, packaged
              to GGUF; memory server onto it if P is active    (all down)
+  delete P   permanently delete P (refuses while P is in use);
+             encrypted P: unmounts, then deletes the vault ciphertext
 
 A full memory update for profile P is: process P -> train P -> chat
 EOF
 
 A full memory update for profile P is: process P -> train P -> chat
 EOF
index 8b5fa76..3488161 100755 (executable)
--- a/alyverkko
+++ b/alyverkko
@@ -26,6 +26,8 @@
 #                                 gocryptfs vault at Profiles/P)
 #   ./alyverkko lock [P]          lock P — every unlocked encrypted
 #                                 profile when P omitted
 #                                 gocryptfs vault at Profiles/P)
 #   ./alyverkko lock [P]          lock P — every unlocked encrypted
 #                                 profile when P omitted
+#   ./alyverkko delete P          permanently delete P (plain or
+#                                 encrypted; refuses while P is in use)
 #   ./alyverkko stop              both LLM services down
 #
 # A full memory update for profile P is:
 #   ./alyverkko stop              both LLM services down
 #
 # A full memory update for profile P is:
@@ -54,6 +56,8 @@ Usage: ./alyverkko <command>
                       drill into P's per-conversation observability DBs
   unlock P            unlock encrypted profile P (mount its vault)
   lock [P]            lock P — every unlocked encrypted profile if omitted
                       drill into P's per-conversation observability DBs
   unlock P            unlock encrypted profile P (mount its vault)
   lock [P]            lock P — every unlocked encrypted profile if omitted
+  delete P            permanently delete profile P (no confirmation;
+                      refuses while P is in use — the GUI asks first)
   stop                both LLM services down
 
 A full memory update for profile P is:
   stop                both LLM services down
 
 A full memory update for profile P is:
@@ -89,6 +93,7 @@ case "${1:-}" in
     logs)           shift; exec Tools/observability.py "$@" ;;
     unlock)         shift; exec Tools/vault unlock "$@" ;;
     lock)           shift; exec Tools/vault lock "$@" ;;
     logs)           shift; exec Tools/observability.py "$@" ;;
     unlock)         shift; exec Tools/vault unlock "$@" ;;
     lock)           shift; exec Tools/vault lock "$@" ;;
+    delete)         shift; exec Tools/memory delete "$@" ;;
     stop)           exec Tools/memory off ;;
     -h|--help|help) usage 0 ;;
     *)              usage 2 ;;
     stop)           exec Tools/memory off ;;
     -h|--help|help) usage 0 ;;
     *)              usage 2 ;;
index c463a0e..4f2e89c 100644 (file)
@@ -29,6 +29,7 @@ import javafx.application.Platform;
 import javafx.geometry.Insets;
 import javafx.scene.Scene;
 import javafx.scene.control.Button;
 import javafx.geometry.Insets;
 import javafx.scene.Scene;
 import javafx.scene.control.Button;
+import javafx.scene.control.ButtonBar;
 import javafx.scene.control.ButtonType;
 import javafx.scene.control.Dialog;
 import javafx.scene.control.Label;
 import javafx.scene.control.ButtonType;
 import javafx.scene.control.Dialog;
 import javafx.scene.control.Label;
@@ -48,8 +49,11 @@ import javafx.stage.Stage;
 
 /**
  * The {@code ./alyverkko gui} overview panel: every persona with
 
 /**
  * The {@code ./alyverkko gui} overview panel: every persona with
- * Chat/Process/Train buttons, RAM and VRAM fill bars, and the two
- * LLM service states — all refreshed every two seconds.
+ * Chat/Process/Train buttons and a Delete button (confirmation
+ * dialog first, then {@code ./alyverkko delete P} as a job), RAM and
+ * VRAM fill bars, and the two LLM service states (each with a Stop
+ * button that stops the service manually to free its memory) — all
+ * refreshed every two seconds.
  *
  * <p>The panel itself never touches the profile lock and never runs
  * pipeline phases in-process: buttons spawn the facade
  *
  * <p>The panel itself never touches the profile lock and never runs
  * pipeline phases in-process: buttons spawn the facade
@@ -94,8 +98,10 @@ public final class Overview {
     private Label swapText;
     private Circle chatDot;
     private Label chatLabel;
     private Label swapText;
     private Circle chatDot;
     private Label chatLabel;
+    private Button chatStop;
     private Circle memDot;
     private Label memLabel;
     private Circle memDot;
     private Label memLabel;
+    private Button memStop;
     private Label jobLabel;
     private TextArea logArea;
 
     private Label jobLabel;
     private TextArea logArea;
 
@@ -148,11 +154,13 @@ public final class Overview {
 
         chatDot = dot();
         chatLabel = new Label();
 
         chatDot = dot();
         chatLabel = new Label();
+        chatStop = stopButton("stop-chat", CHAT_UNIT, "chat");
         memDot = dot();
         memLabel = new Label();
         memDot = dot();
         memLabel = new Label();
+        memStop = stopButton("stop-memory", MEM_UNIT, "memory");
         final VBox services = new VBox(4,
         final VBox services = new VBox(4,
-                new HBox(8, chatDot, chatLabel),
-                new HBox(8, memDot, memLabel));
+                new HBox(8, chatDot, chatLabel, chatStop),
+                new HBox(8, memDot, memLabel, memStop));
 
         jobLabel = new Label("");
         logArea = new TextArea();
 
         jobLabel = new Label("");
         logArea = new TextArea();
@@ -258,11 +266,13 @@ public final class Overview {
         chatDot.setFill(serviceColor(chatUp, chatHealthy));
         chatLabel.setText("chat :" + CHAT_PORT + " — "
                 + serviceText(chatUp, chatHealthy));
         chatDot.setFill(serviceColor(chatUp, chatHealthy));
         chatLabel.setText("chat :" + CHAT_PORT + " — "
                 + serviceText(chatUp, chatHealthy));
+        chatStop.setDisable(!chatUp || busy);
         memDot.setFill(serviceColor(memUp, memHealthy));
         memLabel.setText("memory :" + MEM_PORT + " — "
                 + serviceText(memUp, memHealthy)
                 + (memUp && !served.isEmpty()
                         ? ", serving " + served : ""));
         memDot.setFill(serviceColor(memUp, memHealthy));
         memLabel.setText("memory :" + MEM_PORT + " — "
                 + serviceText(memUp, memHealthy)
                 + (memUp && !served.isEmpty()
                         ? ", serving " + served : ""));
+        memStop.setDisable(!memUp || busy);
     }
 
     /** Fills one meter row from a probe result. */
     }
 
     /** Fills one meter row from a probe result. */
@@ -342,10 +352,54 @@ public final class Overview {
                 + persona.name(), "./alyverkko", "memory-train",
                 persona.name()));
 
                 + persona.name(), "./alyverkko", "memory-train",
                 persona.name()));
 
+        final Button delete = new Button("Delete");
+        delete.setId("delete-" + persona.name());
+        delete.setDisable(busy);
+        delete.setOnAction(event -> confirmDelete(persona));
+
         return persona.encrypted()
                 ? new HBox(8, name, spacer, vaultToggle, chat,
         return persona.encrypted()
                 ? new HBox(8, name, spacer, vaultToggle, chat,
-                        process, train)
-                : new HBox(8, name, spacer, chat, process, train);
+                        process, train, delete)
+                : new HBox(8, name, spacer, chat, process, train,
+                        delete);
+    }
+
+    /**
+     * The Delete confirmation: states exactly what is permanently
+     * deleted, then runs {@code ./alyverkko delete} as a job — its
+     * refusals (profile in use) land in the log pane like any job
+     * output, and the row vanishes on the next refresh.
+     */
+    private void confirmDelete(final Personas.Persona persona) {
+        final String what;
+        if (!persona.encrypted()) {
+            what = "the profile '" + persona.name() + "'?\n"
+                    + "Prompt, conversations, simulations and trained "
+                    + "models are lost for good.";
+        } else if (persona.locked()) {
+            what = "the encrypted vault of '" + persona.name() + "'?\n"
+                    + "Only ciphertext exists while locked — it is "
+                    + "lost for good.";
+        } else {
+            what = "the encrypted profile '" + persona.name() + "'?\n"
+                    + "The vault ciphertext and everything in the "
+                    + "mounted profile are lost for good.";
+        }
+        final Dialog<ButtonType> dialog = new Dialog<>();
+        dialog.setTitle("Delete profile");
+        dialog.setHeaderText("Permanently delete " + what);
+        final ButtonType deleteType =
+                new ButtonType("Delete", ButtonBar.ButtonData.OK_DONE);
+        dialog.getDialogPane().getButtonTypes()
+                .addAll(deleteType, ButtonType.CANCEL);
+        dialog.getDialogPane().getStylesheets()
+                .add(css("dialog-dark.css"));
+        final boolean confirmed = dialog.showAndWait()
+                .filter(deleteType::equals).isPresent();
+        if (confirmed) {
+            runJob("delete " + persona.name(),
+                    "./alyverkko", "delete", persona.name());
+        }
     }
 
     /**
     }
 
     /**
@@ -580,8 +634,15 @@ public final class Overview {
         final Thread runner = new Thread(() -> {
             int exit = -1;
             try {
         final Thread runner = new Thread(() -> {
             int exit = -1;
             try {
-                final Process process = new ProcessBuilder(command)
-                        .redirectErrorStream(true).start();
+                final ProcessBuilder builder =
+                        new ProcessBuilder(command)
+                                .redirectErrorStream(true);
+                // Facade jobs see the same profiles root as the panel
+                // (the vault script and Tools/memory delete read it;
+                // tests point both at a @TempDir).
+                builder.environment().put("ALYVERKKO_PROFILES_ROOT",
+                        profilesRoot.toAbsolutePath().toString());
+                final Process process = builder.start();
                 job = process;
                 try (BufferedReader out = new BufferedReader(
                         new InputStreamReader(
                 job = process;
                 try (BufferedReader out = new BufferedReader(
                         new InputStreamReader(
@@ -621,6 +682,51 @@ public final class Overview {
         logArea.positionCaret(logArea.getLength());
     }
 
         logArea.positionCaret(logArea.getLength());
     }
 
+    // --------------------------------------------------------------
+    // Manual service stop (free RAM)
+    // --------------------------------------------------------------
+
+    /** One service row's Stop button. */
+    private Button stopButton(final String id, final String unit,
+                              final String label) {
+        final Button button = new Button("Stop");
+        button.setId(id);
+        button.setOnAction(event -> stopService(unit, label));
+        return button;
+    }
+
+    /**
+     * Stops one LLM service on the user's request, to free its
+     * RAM/VRAM. Runs off the FX thread on the scheduler; the outcome
+     * goes to the log pane and a prompt poll refreshes the dots and
+     * the memory bars. A plain {@code systemctl --user stop} is
+     * enough — {@code Profiles/.served} may stay stale (the panel
+     * only shows "serving" while the unit is up, and
+     * {@code Tools/memory use} restarts an inactive unit regardless).
+     */
+    private void stopService(final String unit, final String label) {
+        scheduler.execute(() -> {
+            try {
+                final Process process = new ProcessBuilder("systemctl",
+                        "--user", "stop", unit)
+                        .redirectErrorStream(true).start();
+                final String out = new String(
+                        process.getInputStream().readAllBytes(),
+                        StandardCharsets.UTF_8).strip();
+                final int exit = process.waitFor();
+                Platform.runLater(() -> logLine(exit == 0
+                        ? "[gui] stopped " + unit + " (" + label + ")"
+                        : "[gui] stop " + unit + " failed (exit "
+                                + exit + (out.isEmpty() ? ")"
+                                        : "): " + out)));
+            } catch (final IOException | InterruptedException e) {
+                Platform.runLater(() -> logLine("[gui] stop " + unit
+                        + " failed: " + e));
+            }
+            poll(); // refresh dots and the memory bars promptly
+        });
+    }
+
     // --------------------------------------------------------------
     // Probes
     // --------------------------------------------------------------
     // --------------------------------------------------------------
     // Probes
     // --------------------------------------------------------------
diff --git a/src/test/java/eu/svjatoslav/alyverkko/assistant/ProfileDeleteTest.java b/src/test/java/eu/svjatoslav/alyverkko/assistant/ProfileDeleteTest.java
new file mode 100644 (file)
index 0000000..80b1d4d
--- /dev/null
@@ -0,0 +1,174 @@
+package eu.svjatoslav.alyverkko.assistant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Drives the real {@code Tools/memory delete} against a temporary
+ * profiles root: plain deletion, unknown/invalid names, the
+ * chat-in-use and stale-ACTIVE guards, and encrypted vault deletion
+ * (the mounted case needs real gocryptfs and is skipped without it).
+ */
+class ProfileDeleteTest {
+
+    private static final String PASSPHRASE = "correct horse battery";
+
+    @TempDir
+    Path profilesRoot;
+
+    @AfterEach
+    void lockEverything() throws Exception {
+        // A mounted vault would block @TempDir cleanup.
+        runScript("Tools/vault", null, "lock");
+    }
+
+    @Test
+    void deletesPlainProfile() throws Exception {
+        final Path profile = Files.createDirectories(
+                profilesRoot.resolve("Alpha/Conversations"));
+        Files.writeString(profile.resolve("prompt.txt"), "You are A.\n");
+        Files.writeString(
+                profilesRoot.resolve("Alpha/Conversations/t.jsonl"),
+                "{}\n");
+
+        final Result result = delete("Alpha");
+        assertEquals(0, result.exit(), result.output());
+        assertFalse(Files.exists(profilesRoot.resolve("Alpha")));
+        assertTrue(result.output().contains("deleted profile 'Alpha'"));
+    }
+
+    @Test
+    void refusesUnknownAndInvalidNames() throws Exception {
+        assertEquals(2, delete("NoSuch").exit());
+        assertEquals(2, delete("a/b").exit());
+        assertEquals(2, delete("").exit());
+    }
+
+    @Test
+    void refusesWhileChatRunsOnTheProfile() throws Exception {
+        Files.createDirectories(profilesRoot.resolve("Alpha"));
+        Files.writeString(profilesRoot.resolve("Alpha/prompt.txt"), "x\n");
+        // A live PID in .lock + ACTIVE naming the profile = a chat on
+        // it is running.
+        Files.writeString(profilesRoot.resolve(".lock"),
+                String.valueOf(ProcessHandle.current().pid()));
+        Files.writeString(profilesRoot.resolve("ACTIVE"), "Alpha");
+
+        final Result result = delete("Alpha");
+        assertEquals(2, result.exit(), result.output());
+        assertTrue(result.output().contains("chat on 'Alpha'"),
+                result.output());
+        assertTrue(Files.exists(profilesRoot.resolve("Alpha")),
+                "refusal must not delete");
+    }
+
+    @Test
+    void staleActiveIsClearedOnDelete() throws Exception {
+        Files.createDirectories(profilesRoot.resolve("Alpha"));
+        Files.writeString(profilesRoot.resolve("Alpha/prompt.txt"), "x\n");
+        // No live lock: ACTIVE is stale, deletion proceeds and drops it.
+        Files.writeString(profilesRoot.resolve("ACTIVE"), "Alpha");
+
+        assertEquals(0, delete("Alpha").exit());
+        assertFalse(Files.exists(profilesRoot.resolve("ACTIVE")));
+    }
+
+    @Test
+    void deletesLockedVaultWithoutGocryptfs() throws Exception {
+        // A locked vault is ciphertext only: deletion needs no mount
+        // handling, so no gocryptfs either.
+        Files.createDirectories(profilesRoot.resolve("Secret.vault"));
+        Files.writeString(
+                profilesRoot.resolve("Secret.vault/gocryptfs.conf"),
+                "{}\n");
+
+        final Result result = delete("Secret");
+        assertEquals(0, result.exit(), result.output());
+        assertFalse(Files.exists(profilesRoot.resolve("Secret.vault")));
+    }
+
+    @Test
+    void deletesMountedVaultViaRealGocryptfs() throws Exception {
+        assumeTrue(onPath("gocryptfs") && onPath("fusermount"),
+                "gocryptfs/fusermount not installed");
+        assertEquals(0, runScript("Tools/vault", PASSPHRASE,
+                "init", "Secret").exit());
+        Files.writeString(profilesRoot.resolve("Secret/prompt.txt"),
+                "top secret\n");
+
+        final Result result = delete("Secret");
+        assertEquals(0, result.exit(), result.output());
+        assertFalse(Files.exists(profilesRoot.resolve("Secret.vault")));
+        assertFalse(mountpoint(profilesRoot.resolve("Secret")),
+                "vault unmounted");
+    }
+
+    /** True when dir is an active mountpoint. */
+    private static boolean mountpoint(final Path dir)
+            throws IOException, InterruptedException {
+        return new ProcessBuilder("mountpoint", "-q", dir.toString())
+                .start().waitFor() == 0;
+    }
+
+    /** True when the named binary is on PATH. */
+    private static boolean onPath(final String binary) {
+        for (final String dir
+                : System.getenv("PATH").split(java.io.File.pathSeparator)) {
+            if (Files.isExecutable(Path.of(dir, binary))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Script result: exit code and merged stdout/stderr. */
+    private record Result(int exit, String output) {
+    }
+
+    private Result delete(final String name)
+            throws IOException, InterruptedException {
+        return runScript("Tools/memory", null, "delete", name);
+    }
+
+    /**
+     * Runs a Tools script with the optional passphrase on stdin and
+     * ALYVERKKO_PROFILES_ROOT pointing at the temp root.
+     */
+    private Result runScript(final String script, final String stdin,
+                             final String... args)
+            throws IOException, InterruptedException {
+        final String[] command = new String[args.length + 1];
+        command[0] = script;
+        System.arraycopy(args, 0, command, 1, args.length);
+        final ProcessBuilder builder = new ProcessBuilder(command);
+        builder.environment().put("ALYVERKKO_PROFILES_ROOT",
+                profilesRoot.toAbsolutePath().toString());
+        builder.redirectErrorStream(true);
+        final Process process = builder.start();
+        final OutputStream in = process.getOutputStream();
+        try {
+            if (stdin != null) {
+                in.write((stdin + "\n").getBytes(StandardCharsets.UTF_8));
+            }
+            in.close();
+        } catch (final IOException ignored) {
+            // dead pipe: the script exited before reading stdin
+        }
+        final String output = new String(
+                process.getInputStream().readAllBytes(),
+                StandardCharsets.UTF_8);
+        return new Result(process.waitFor(), output);
+    }
+}
index b56763c..5cd8254 100644 (file)
@@ -133,11 +133,37 @@ class OverviewGuiTest {
                 .getText().equals("Unlock"));
         assertTrue(robot.lookup("#vault-Alpha").tryQuery().isEmpty());
 
                 .getText().equals("Unlock"));
         assertTrue(robot.lookup("#vault-Alpha").tryQuery().isEmpty());
 
+        // Every row — plain or locked — carries a Delete button.
+        assertTrue(robot.lookup("#delete-Alpha").tryQuery().isPresent());
+        assertTrue(robot.lookup("#delete-Secret").tryQuery().isPresent());
+
         // RAM meter fills in on the first poll; the label carries GiB.
         await(() -> robot.lookup(".overview .label")
                 .queryAll().stream()
                 .map(n -> ((Label) n).getText())
                 .anyMatch(t -> t.contains("GiB used")));
         // RAM meter fills in on the first poll; the label carries GiB.
         await(() -> robot.lookup(".overview .label")
                 .queryAll().stream()
                 .map(n -> ((Label) n).getText())
                 .anyMatch(t -> t.contains("GiB used")));
+
+        // Each service row carries a Stop button, enabled exactly
+        // while its service is up (the test host's real units decide
+        // which; the label and the button update in the same tick).
+        final Button stopChat =
+                robot.lookup("#stop-chat").queryAs(Button.class);
+        final Button stopMemory =
+                robot.lookup("#stop-memory").queryAs(Button.class);
+        assertTrue(stopChat.isDisabled()
+                == serviceText(robot, "chat :").contains("not running"));
+        assertTrue(stopMemory.isDisabled()
+                == serviceText(robot, "memory :").contains("not running"));
+    }
+
+    /** The text of the service label starting with the prefix. */
+    private static String serviceText(final FxRobot robot,
+                                      final String prefix) {
+        return robot.lookup(".overview .label").queryAll().stream()
+                .filter(n -> n instanceof Label)
+                .map(n -> ((Label) n).getText())
+                .filter(t -> t != null && t.startsWith(prefix))
+                .findFirst().orElse("");
     }
 
     /**
     }
 
     /**
@@ -212,6 +238,59 @@ class OverviewGuiTest {
                 .getStyleClass().contains("lock-active"));
     }
 
                 .getStyleClass().contains("lock-active"));
     }
 
+    /**
+     * The Delete flow: clicking Delete opens the confirmation dialog;
+     * Cancel deletes nothing, and confirming runs
+     * {@code ./alyverkko delete Beta} as a job — the profile directory
+     * is gone and its row disappears on the next refresh.
+     */
+    @Test
+    void deleteAsksConfirmationThenDeletes(final FxRobot robot) {
+        await(() -> robot.lookup("#delete-Beta").tryQuery().isPresent());
+
+        // Cancel: dialog closes, nothing is deleted, no job launched.
+        robot.clickOn("#delete-Beta");
+        await(() -> deleteDialogStage() != null);
+        robot.targetWindow(deleteDialogStage());
+        robot.clickOn("Cancel");
+        await(() -> deleteDialogStage() == null);
+        assertTrue(Files.exists(profilesRoot.resolve("Beta")),
+                "cancel must not delete");
+        final TextArea log = robot.lookup(".overview .text-area")
+                .queryAs(TextArea.class);
+        assertFalse(log.getText().contains("./alyverkko delete"));
+
+        // Confirm: the delete job runs and the row disappears.
+        robot.clickOn("#delete-Beta");
+        await(() -> deleteDialogStage() != null);
+        // Click the dialog's own Delete button by node — the rows in
+        // the main window carry identically labeled buttons.
+        final Stage dialog = deleteDialogStage();
+        final Button confirm = dialog.getScene().getRoot()
+                .lookupAll(".button").stream()
+                .filter(n -> n instanceof Button)
+                .map(n -> (Button) n)
+                .filter(b -> "Delete".equals(b.getText()))
+                .findFirst().orElseThrow();
+        robot.targetWindow(dialog);
+        robot.clickOn(confirm);
+        await(() -> deleteDialogStage() == null);
+        await(() -> !Files.exists(profilesRoot.resolve("Beta")));
+        assertTrue(log.getText().contains("./alyverkko delete Beta"),
+                "delete job streamed to the log pane");
+        await(() -> robot.lookup("#chat-Beta").tryQuery().isEmpty());
+    }
+
+    /** The "Delete profile" dialog stage, or null when not showing. */
+    private static Stage deleteDialogStage() {
+        return javafx.stage.Window.getWindows().stream()
+                .filter(javafx.stage.Window::isShowing)
+                .filter(w -> w instanceof Stage)
+                .map(w -> (Stage) w)
+                .filter(s -> "Delete profile".equals(s.getTitle()))
+                .findFirst().orElse(null);
+    }
+
     /** The "Locked profile" dialog stage, or null when not showing. */
     private static Stage dialogStage() {
         return javafx.stage.Window.getWindows().stream()
     /** The "Locked profile" dialog stage, or null when not showing. */
     private static Stage dialogStage() {
         return javafx.stage.Window.getWindows().stream()