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.
91 lines
4.1 KiB
Java
91 lines
4.1 KiB
Java
package dev.ltms.bridged.member;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.io.TempDir;
|
|
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.attribute.FileTime;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* {@link OpenCodeSessionDiscovery} matches an opencode session record by the worker's cwd (its
|
|
* {@code directory}) against opencode's on-disk storage. These tests populate a TEMP storage root
|
|
* themselves — never the operator's real {@code ~/.local/share/opencode}.
|
|
*/
|
|
class OpenCodeSessionDiscoveryTest {
|
|
|
|
/**
|
|
* Write a session record {@code {"id":..., "directory":...}} under
|
|
* {@code <root>/session/<projectID>/<fileName>} and stamp it with a known last-modified time,
|
|
* so "most recently modified wins" is deterministic. Static so the launcher test can reuse it.
|
|
*/
|
|
static void writeRecord(Path root, String projectId, String fileName, String id,
|
|
String directory, long lastModifiedEpochMillis) throws Exception {
|
|
Path dir = root.resolve("session").resolve(projectId);
|
|
Files.createDirectories(dir);
|
|
Path file = dir.resolve(fileName);
|
|
Files.writeString(file, "{\"id\":\"" + id + "\",\"directory\":\"" + directory
|
|
+ "\",\"projectID\":\"" + projectId + "\",\"version\":\"1.1.31\"}");
|
|
Files.setLastModifiedTime(file, FileTime.fromMillis(lastModifiedEpochMillis));
|
|
}
|
|
|
|
@Test
|
|
void findsTheRecordWhoseDirectoryEqualsTheCwd(@TempDir Path root) throws Exception {
|
|
writeRecord(root, "p1", "ses_a.json", "ses_aaa", "/w/a", 1000L);
|
|
writeRecord(root, "p2", "ses_b.json", "ses_bbb", "/w/b", 2000L);
|
|
|
|
assertEquals("ses_bbb", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/b"),
|
|
"the record whose directory equals the cwd is the one found");
|
|
assertEquals("ses_aaa", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"));
|
|
}
|
|
|
|
@Test
|
|
void aNonMatchingDirectoryYieldsNullRatherThanAMismatch(@TempDir Path root) throws Exception {
|
|
writeRecord(root, "p1", "ses_a.json", "ses_aaa", "/w/a", 1000L);
|
|
|
|
assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/other"),
|
|
"no record for this cwd yet → null, not a wrong session");
|
|
}
|
|
|
|
@Test
|
|
void prefersTheMostRecentlyModifiedRecordWhenSeveralMatch(@TempDir Path root) throws Exception {
|
|
writeRecord(root, "p1", "old.json", "ses_old", "/w/a", 1000L);
|
|
writeRecord(root, "p2", "new.json", "ses_new", "/w/a", 5000L);
|
|
|
|
assertEquals("ses_new", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"),
|
|
"the freshest record for the cwd wins");
|
|
}
|
|
|
|
@Test
|
|
void aMissingOrEmptyStorageRootYieldsNullWithoutThrowing(@TempDir Path root) throws Exception {
|
|
// Missing: no session dir at all under the root.
|
|
assertNull(new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"));
|
|
|
|
// Present but empty: a session dir with nothing in it produces no match, not a throw.
|
|
Path emptyRoot = root.resolve("empty");
|
|
Files.createDirectories(emptyRoot.resolve("session"));
|
|
assertNull(new OpenCodeSessionDiscovery(emptyRoot).sessionIdForDirectory("/w/a"));
|
|
}
|
|
|
|
@Test
|
|
void aBlankOrNullDirectoryYieldsNull(@TempDir Path root) {
|
|
OpenCodeSessionDiscovery discovery = new OpenCodeSessionDiscovery(root);
|
|
assertNull(discovery.sessionIdForDirectory(null));
|
|
assertNull(discovery.sessionIdForDirectory(" "));
|
|
}
|
|
|
|
@Test
|
|
void aMalformedRecordIsSkippedRatherThanFatal(@TempDir Path root) throws Exception {
|
|
// A record that fails to parse must not abort the scan of its siblings.
|
|
Path dir = root.resolve("session").resolve("p1");
|
|
Files.createDirectories(dir);
|
|
Files.writeString(dir.resolve("broken.json"), "{not valid json");
|
|
writeRecord(root, "p1", "good.json", "ses_good", "/w/a", 1000L);
|
|
|
|
assertEquals("ses_good", new OpenCodeSessionDiscovery(root).sessionIdForDirectory("/w/a"),
|
|
"an unreadable record is skipped; a later valid one still matches");
|
|
}
|
|
}
|