4875127daa
Every spawned peer is now a member with a role, and the role travels with it
from the spawn call to the roster.
MCP:
bridge_spawn gains role: architect | dev | reviewer (default dev). An
unknown role is refused with the valid spellings in the message.
bridge_list returns "members" instead of "workers"; each row carries both
role (what it is for) and profile (which backend it runs on).
The spawn result echoes the role back, so a spawn that fell back to dev is
visible rather than silent.
REST:
GET/POST /members and DELETE /members/{paneId} replace /workers.
POST accepts role= as a query param or a body field; an unknown role is 400.
Code:
dev.ltms.bridged.worker package -> dev.ltms.bridged.member
WorkerSession -> MemberSession, plus a MemberRole role component
WorkerPresence -> MemberPresence
SessionManager.acquire gains a role parameter; the existing overloads keep
working and default to DEV, which is exactly what "worker" used to mean.
ClaudeCodeLauncher and OpenCodeLauncher keep their names on purpose — they
are named after the backend, not the role.
Not done here: the launch charter is still one string for every role, so a
member is told its role by nobody yet. That is the next ticket.
mvn clean install: 583 tests, 0 failures, 0 errors, BUILD SUCCESS.
123 lines
5.6 KiB
Java
123 lines
5.6 KiB
Java
package dev.ltms.bridged.member;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.stream.Stream;
|
|
|
|
/**
|
|
* Resolves the opencode session id for a bridged worker from opencode's on-disk storage — the
|
|
* only place this adapter touches opencode's private layout, and deliberately the <em>only</em>
|
|
* class that does.
|
|
*
|
|
* <p><strong>Why this is isolated behind one seam.</strong> The layout is version-coupled and not a
|
|
* stable contract: opencode writes one JSON file per session under
|
|
* {@code <storageRoot>/session/<projectID>/<ses_*.json>}, and each record carries a
|
|
* {@code "version"} field (e.g. {@code "1.1.31"}), so the exact directory shape, file naming, and
|
|
* field names can move between opencode releases. opencode also ships a headless HTTP server that
|
|
* may supersede file scanning entirely. Everything this adapter knows about that private storage —
|
|
* its shape, naming, and field names — lives here, so a layout change, or a switch to the HTTP
|
|
* server, changes exactly one class and nothing in {@link OpenCodeLauncher}.
|
|
*
|
|
* <p>The determinism that makes this useful is structural, not a guess: every bridged worker runs
|
|
* in its own unique git worktree, so the record's {@code directory} (its project root) equals the
|
|
* worker's cwd identifies <em>its</em> session unambiguously. We match on {@code directory} rather
|
|
* than diffing {@code opencode session list} before/after — that races under concurrent spawns, and
|
|
* the CLI listing does not even show the directory.
|
|
*
|
|
* <p>All reads are best-effort and never throw: a missing or unreadable storage root, a record that
|
|
* fails to parse, or a directory with no record yet all yield {@code null}, and the caller (the
|
|
* session handle) treats that as "identity not resolved yet" and retries later.
|
|
*/
|
|
final class OpenCodeSessionDiscovery {
|
|
|
|
private final Path storageRoot; // e.g. ~/.local/share/opencode (injectable for tests)
|
|
private final ObjectMapper json;
|
|
|
|
OpenCodeSessionDiscovery(Path storageRoot) {
|
|
this.storageRoot = storageRoot;
|
|
this.json = new ObjectMapper();
|
|
}
|
|
|
|
/**
|
|
* The opencode session id whose record references {@code directory} (the worker's cwd), or
|
|
* {@code null} when no record matches yet. When several records share the directory — e.g.
|
|
* repeated spawns into the same worktree — the <em>most recently modified</em> one wins: it is
|
|
* the session the pane most likely corresponds to.
|
|
*
|
|
* <p>Never throws: a missing {@code storageRoot}, an unreadable/malformed record, or a
|
|
* directory that has not been persisted yet all resolve to {@code null} rather than failing a
|
|
* spawn. A bridged worker's session record is written lazily (when the session is first
|
|
* persisted), so {@code null} here is the normal answer right after the pane is ready, and the
|
|
* caller retries later.
|
|
*
|
|
* @param directory the worker's cwd, as resolved for this spawn
|
|
* @return the matching session id, or {@code null} if none is known yet
|
|
*/
|
|
String sessionIdForDirectory(String directory) {
|
|
if (directory == null || directory.isBlank()) {
|
|
return null;
|
|
}
|
|
Path sessionRoot = storageRoot.resolve("session");
|
|
if (!Files.isDirectory(sessionRoot)) {
|
|
return null;
|
|
}
|
|
String best = null;
|
|
long bestMtime = Long.MIN_VALUE;
|
|
try (Stream<Path> projectDirs = Files.list(sessionRoot)) {
|
|
for (Path projectDir : projectDirs.filter(Files::isDirectory).toList()) {
|
|
try (Stream<Path> records = Files.list(projectDir)) {
|
|
for (Path record : records.toList()) {
|
|
String id = matchId(record, directory);
|
|
if (id == null) {
|
|
continue;
|
|
}
|
|
long mtime = lastModifiedEpochMillis(record);
|
|
if (mtime > bestMtime) {
|
|
bestMtime = mtime;
|
|
best = id;
|
|
}
|
|
}
|
|
} catch (IOException ignored) {
|
|
// one project dir unreadable — skip it; another may still match
|
|
}
|
|
}
|
|
} catch (IOException ignored) {
|
|
// storage root vanished or became unreadable — "no session known yet"
|
|
return null;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* The record's session id when it references {@code directory}, else {@code null}. A record
|
|
* that is not JSON, lacks {@code id}/{@code directory}, or points at a different directory is
|
|
* simply not our session; a malformed one is skipped, never fatal.
|
|
*/
|
|
private String matchId(Path record, String directory) {
|
|
try {
|
|
JsonNode node = json.readTree(record.toFile());
|
|
JsonNode id = node == null ? null : node.get("id");
|
|
JsonNode dir = node == null ? null : node.get("directory");
|
|
if (id == null || dir == null || !directory.equals(dir.asText())) {
|
|
return null;
|
|
}
|
|
return id.asText();
|
|
} catch (IOException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** The record's last-modified epoch ms, or {@code Long.MIN_VALUE} if unreadable (never wins). */
|
|
private static long lastModifiedEpochMillis(Path record) {
|
|
try {
|
|
return Files.getLastModifiedTime(record).toMillis();
|
|
} catch (IOException e) {
|
|
return Long.MIN_VALUE;
|
|
}
|
|
}
|
|
}
|