WorkingSetLease.java

package network.ike.plugin.ws;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * Bridge to the working-set lease protocol in {@code ~/ike-dev/scripts/lease.sh}.
 *
 * <p>A working set — any project root under {@code ~/ike-dev} — has one
 * writer at a time across the machines that share that folder. A
 * {@code ws:} goal that rewrites branches, versions and history is
 * emphatically a writer, so it confirms it holds the lease before it
 * starts. Design: {@code dev-working-set-lease} in ike-lab-documents,
 * IKE-Network/ike-issues#1002; this half is #1005.
 *
 * <p><strong>Inert outside that setup.</strong> Every check is an
 * existence check first: no {@code lease.sh}, no {@code ~/.ike-machine-id},
 * or a project that is not under {@code ~/ike-dev}, and the goal proceeds
 * exactly as it did before. That matters because this plugin is published
 * and run by people with no Syncthing-paired fleet, for whom the lease
 * protocol does not exist. It fails open for the same reason the Claude
 * fence does: a coordination aid that can wedge a build is worse than no
 * coordination aid.
 *
 * <p><strong>The protocol is not reimplemented here.</strong> Epoch
 * arithmetic, staleness horizons and conflict reconciliation live once, in
 * the shell script that the IDE plugin and the Claude hook also call. A
 * fencing system whose halves disagree is worse than one with no fencing
 * at all.
 */
final class WorkingSetLease {

    /**
     * Seconds to allow the confirming call. It deliberately sleeps out the
     * sync layer's propagation window — about 25 seconds — before reading
     * the record back, so this has to be generous. Cutting it short would
     * report a race lost that was never run.
     */
    private static final long CONFIRM_TIMEOUT_SECONDS = 120L;

    private WorkingSetLease() { }

    /**
     * The outcome of asking the lease protocol whether this machine may write.
     *
     * @param verdict  what the protocol decided
     * @param detail   the protocol's own explanation, for the operator; empty
     *                 when there is nothing to say
     */
    record Decision(Verdict verdict, String detail) { }

    /** What {@link #confirm} concluded. */
    enum Verdict {
        /** This machine holds the lease; proceed. */
        HELD,
        /** No lease machinery, or not a working set. Proceed. */
        NOT_APPLICABLE,
        /** Another machine holds it, or won the race. Do not write. */
        FENCED
    }

    /**
     * Confirms this machine holds the working set's lease, waiting out the
     * sync layer's propagation window and reading the record back.
     *
     * @param workspaceRoot the directory the goal is about to write to
     * @return the decision; never {@code null}
     */
    static Decision confirm(Path workspaceRoot) {
        Path script = script();
        if (script == null || workspaceRoot == null) {
            return new Decision(Verdict.NOT_APPLICABLE, "");
        }
        Result resolved = run(script, 10L, "resolve", workspaceRoot.toString());
        if (resolved.exitCode() != 0 || resolved.output().isBlank()) {
            return new Decision(Verdict.NOT_APPLICABLE, "");
        }
        String workingSet = resolved.output().trim();
        Result confirmed = run(script, CONFIRM_TIMEOUT_SECONDS,
                "ensure", workingSet, "--confirm");
        if (confirmed.exitCode() == 0) {
            return new Decision(Verdict.HELD, workingSet);
        }
        // A negative exit is this bridge failing to run the script at all —
        // not the protocol refusing. Fail open.
        if (confirmed.exitCode() < 0) {
            return new Decision(Verdict.NOT_APPLICABLE, "");
        }
        return new Decision(Verdict.FENCED, confirmed.output().trim());
    }

    /**
     * Locates the lease script, requiring the machine identity beside it.
     *
     * @return the script path, or {@code null} when this machine has no
     *         lease machinery
     */
    private static Path script() {
        String home = System.getProperty("user.home");
        if (home == null) {
            return null;
        }
        Path script = Path.of(home, "ike-dev", "scripts", "lease.sh");
        Path identity = Path.of(home, ".ike-machine-id");
        return Files.isExecutable(script) && Files.exists(identity) ? script : null;
    }

    private static Result run(Path script, long timeoutSeconds, String... args) {
        try {
            Process process = new ProcessBuilder(
                    concat(script.toString(), args))
                    .redirectErrorStream(true)
                    .start();
            StringBuilder output = new StringBuilder();
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(
                    process.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    output.append(line).append('\n');
                }
            }
            if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
                process.destroyForcibly();
                return new Result(-1, output.toString());
            }
            return new Result(process.exitValue(), output.toString());
        } catch (IOException e) {
            return new Result(-1, "");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new Result(-1, "");
        }
    }

    private static List<String> concat(String head, String... tail) {
        java.util.ArrayList<String> command = new java.util.ArrayList<>();
        command.add(head);
        command.addAll(List.of(tail));
        return command;
    }

    private record Result(int exitCode, String output) { }
}