wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<script lang="ts">
  // The children an operation is running.
  //
  // These were a wall of identical chips reading `bash` — the first word of
  // every argv, which is the one word they all share, so eighty of them said
  // nothing eighty times. What a person wants from a running child is which
  // one it is, how long it has been going, and a way to stop it.
  import { killOpProcesses } from "../../api/client.js";
  import { formatElapsed, ticker } from "../../stores/elapsed.svelte.js";
  import type { RunningChild } from "../../stores/op-output-view.js";

  let { opId, running }: { opId: string; running: RunningChild[] } = $props();

  let busy = $state<number | "all" | null>(null);
  let error = $state<string | null>(null);
  /** What a stop actually achieved. */
  let note = $state<string | null>(null);
  /** Processes that took the signal and carried on. */
  let stubborn = $state<number[]>([]);

  // A clock only while there is something to count. Children come and go, so
  // the watch follows the list rather than the tab.
  $effect(() => (running.length > 0 ? ticker.watch() : undefined));

  /** The command as run, which is what tells two `bash` children apart. */
  function commandOf(child: RunningChild): string {
    // `bash -c "…"` is the shape nearly all of these take, and the script is
    // the only part that differs. Shown whole rather than as its first word.
    const dashC = child.argv.indexOf("-c");
    if (dashC >= 0 && child.argv[dashC + 1]) return child.argv[dashC + 1]!;
    return child.argv.join(" ");
  }

  async function kill(
    request: { pids?: number[]; all?: boolean },
    mark: number | "all",
    force = false,
  ) {
    busy = mark;
    error = null;
    note = null;
    try {
      const outcome = await killOpProcesses(opId, { ...request, signal: force ? "KILL" : "TERM" });
      // Both halves are reported. Saying nothing on success is what made this
      // look like clicking did nothing at all.
      if (outcome.refused.length > 0) {
        error = outcome.refused.map((r) => `${r.pid}: ${r.reason}`).join("; ");
        // Something that took the signal and kept running can be stopped
        // outright, so the offer to do that is put in front of the reader
        // rather than left for them to work out.
        stubborn = outcome.refused
          .filter((r) => r.reason.includes("still running"))
          .map((r) => r.pid);
      } else {
        stubborn = [];
      }
      if (outcome.signalled.length > 0) {
        note = `stopped ${outcome.signalled.length} process${outcome.signalled.length === 1 ? "" : "es"}`;
      }
    } catch (thrown) {
      error = thrown instanceof Error ? thrown.message : String(thrown);
    } finally {
      busy = null;
    }
  }
</script>

<div class="processes">
  <div class="head">
    <span class="count">
      {running.length} running child{running.length === 1 ? "" : "ren"}
    </span>
    {#if running.length > 0}
      <button
        class="kill-all"
        disabled={busy !== null}
        onclick={() => kill({ all: true }, "all")}
      >
        {busy === "all" ? "stopping…" : "Stop all"}
      </button>
    {/if}
  </div>

  {#if error}
    <p class="error" role="alert">
      {error}
      {#if stubborn.length > 0}
        <button class="kill" onclick={() => kill({ pids: stubborn }, "all", true)}>
          Stop {stubborn.length === 1 ? "it" : "them"} outright
        </button>
      {/if}
    </p>
  {/if}

  {#if note}
    <p class="note" role="status">{note}</p>
  {/if}

  {#if running.length === 0}
    <p class="empty">
      Nothing is running. A child appears here the moment the run spawns it and
      leaves when it exits.
    </p>
  {:else}
    <div class="rows" role="list">
      {#each running as child (child.id)}
        <div class="row" role="listitem">
          <span class="pid" title="process id">{child.pid}</span>
          <span class="command" title={child.argv.join(" ")}>{commandOf(child)}</span>
          {#if child.cwd}<span class="cwd" title={child.cwd}>{child.cwd}</span>{/if}
          <span class="elapsed">
            {child.startedAt ? formatElapsed(child.startedAt, undefined, ticker.now) : "—"}
          </span>
          <button
            class="kill"
            disabled={busy !== null}
            onclick={() => kill({ pids: [child.pid] }, child.pid)}
          >
            {busy === child.pid ? "…" : "Stop"}
          </button>
        </div>
      {/each}
    </div>
  {/if}
</div>

<style>
  .processes {
    display: flex;
    flex-direction: column;
    min-height: 0;
    flex: 1;
    overflow: hidden;
  }

  .head {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-10);
    padding: var(--wb-spacing-6) var(--wb-spacing-12);
    border-bottom: 1px solid var(--vscode-panel-border);
  }

  .count {
    flex: 1;
    opacity: 0.8;
  }

  .rows {
    flex: 1;
    min-height: 0;
    overflow: auto;
  }

  .row {
    display: flex;
    align-items: baseline;
    gap: var(--wb-spacing-10);
    padding: var(--wb-spacing-3) var(--wb-spacing-12);
    font-size: 0.92em;
  }

  .row:hover {
    background: var(--vscode-list-hoverBackground);
  }

  .pid {
    flex: 0 0 62px;
    font-family: var(--vscode-editor-font-family, monospace);
    font-variant-numeric: tabular-nums;
    opacity: 0.75;
  }

  .command {
    flex: 1;
    min-width: 0;
    font-family: var(--vscode-editor-font-family, monospace);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .cwd {
    flex: 0 0 auto;
    max-width: 22ch;
    opacity: 0.5;
    font-size: 0.88em;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    direction: rtl;
    text-align: left;
  }

  .elapsed {
    flex: 0 0 62px;
    text-align: right;
    font-variant-numeric: tabular-nums;
    color: var(--vscode-testing-iconQueued, #cca700);
  }

  .kill,
  .kill-all {
    padding: var(--wb-spacing-1) var(--wb-spacing-9);
    border: 1px solid var(--vscode-panel-border);
    border-radius: var(--wb-corner-radius-3);
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.9em;
    cursor: pointer;
  }

  .kill:hover:not(:disabled),
  .kill-all:hover:not(:disabled) {
    border-color: var(--vscode-testing-iconFailed, #f44336);
    color: var(--vscode-testing-iconFailed, #f44336);
  }

  .kill:disabled,
  .kill-all:disabled {
    opacity: 0.5;
    cursor: default;
  }

  .note {
    margin: 0;
    padding: var(--wb-spacing-6) var(--wb-spacing-12);
    border-left: 2px solid var(--vscode-testing-iconPassed, #4caf50);
    background: var(--vscode-inputValidation-infoBackground, rgba(76, 175, 80, 0.1));
  }

  .empty,
  .error {
    margin: 0;
    padding: var(--wb-spacing-10) var(--wb-spacing-12);
  }

  .empty {
    opacity: 0.6;
  }

  .error {
    border-left: 2px solid var(--vscode-testing-iconFailed, #f44336);
    background: var(--vscode-inputValidation-errorBackground, rgba(244, 67, 54, 0.12));
  }
</style>