Compare commits

...
3 changed files with 80 additions and 2 deletions

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--member <name>", "Member name for pod attach mode")
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--display-name <name>", "Display label inside tmux; external session address outside tmux (bare names gain @rig)")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
.action(async (opts: {
@@ -343,9 +343,10 @@ export class SelfAttachService {
return context;
}
const explicit = context?.attachmentType === "external_cli" ? context.sessionName?.trim() : displayName?.trim();
const sessionName = explicit || logicalId.replace(/\./g, "-");
return {
attachmentType: "external_cli",
sessionName: explicit || `${logicalId.replace(/\./g, "-")}@${rigName}`,
sessionName: sessionName.includes("@") ? sessionName : `${sessionName}@${rigName}`,
};
}
@@ -16,6 +16,9 @@ import { EventBus } from "../src/domain/event-bus.js";
import { SelfAttachService } from "../src/domain/self-attach-service.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { makeTopologyDestinationCheck } from "../src/domain/queue-destination-check.js";
import { RigLifecycleService } from "../src/domain/rig-lifecycle-service.js";
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { WhoamiService } from "../src/domain/whoami-service.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
@@ -49,6 +52,80 @@ describe("attached team address preserves physical custody", () => {
afterEach(() => { db.close(); rmSync(transcriptRoot, { recursive: true, force: true }); });
const context = (tmuxSession: string) => ({ attachmentType: "tmux" as const, tmuxSession, tmuxWindow: "@12", tmuxPane: "%42" });
it("keeps a legacy bare name and its work history through unclaim and same-node re-attach", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "people.agent", { runtime: "terminal" });
// Persist the pre-fix external attachment format, then use the real release path.
sessions.updateBinding(node.id, { attachmentType: "external_cli", externalSessionName: "desk" });
const old = sessions.registerClaimedSession(node.id, "desk");
const queue = new QueueRepository(db, bus, {
validateRig: makeTopologyDestinationCheck({ findRigsByName: name => rigs.findRigsByName(name), loadHumanRegistry: () => { throw new Error("no human address in this fixture"); } }),
resolveSessionAddresses: name => sessions.sessionAddresses(name),
});
const sent = await queue.create({ sourceSession: "desk", destinationSession: "operator@team", body: "retained work", nudge: false });
const lifecycle = new RigLifecycleService({ db, rigRepo: rigs, sessionRegistry: sessions, eventBus: bus,
discoveryRepo: new DiscoveryRepository(db), queueRepo: queue });
expect(await lifecycle.unclaimSession(old.id)).toMatchObject({ ok: true, nodeId: node.id, sessionName: "desk" });
expect(sessions.getBindingForNode(node.id)).toBeNull();
const released = db.prepare("SELECT * FROM sessions WHERE id=?").get(old.id);
expect(released).toMatchObject({ session_name: "desk", status: "detached" });
const response = await app.request(`/api/rigs/${rig.id}/attach-self`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logicalId: node.logicalId, attachmentType: "external_cli", displayName: "desk" }),
});
expect(response.status).toBe(201);
const result = await response.json() as { nodeId: string; sessionId: string; sessionName: string; senderAddress: string; env: { OPENRIG_SESSION_NAME: string } };
expect(result.nodeId).toBe(node.id);
expect(db.prepare("SELECT * FROM sessions WHERE id=?").get(old.id)).toEqual(released);
expect(queue.getById(sent.qitemId)?.sourceSession).toBe("desk");
expect(sessions.getBindingForNode(node.id)).toMatchObject({ attachmentType: "external_cli", externalSessionName: "desk" });
expect(result).toMatchObject({ sessionName: "desk", senderAddress: "desk", env: { OPENRIG_SESSION_NAME: "desk" } });
expect(db.prepare("SELECT session_name FROM sessions WHERE id=?").get(result.sessionId)).toEqual({ session_name: "desk" });
expect(queue.whoami(result.env.OPENRIG_SESSION_NAME).asSource.total).toBe(1);
// This preserves the legacy limitation, rather than claiming an implicit repair.
expect(queue.acceptsDestination(result.senderAddress)).toBe(false);
});
it("keeps the derived default byte-identical for a member containing @", async () => {
const rig = rigs.createRig("team"); pods.createPod(rig.id, "people", "People");
expect(await attach.attachToPod({ rigId: rig.id, podNamespace: "people", memberName: "a@b", runtime: "terminal" }))
.toMatchObject({ ok: true, sessionName: "people-a@b@team", senderAddress: "people-a@b@team" });
});
it.each(["node", "pod"].flatMap(mode => [
{ mode, displayName: "desk", address: "desk@team" },
{ mode, displayName: "custom-desk@team", address: "custom-desk@team" },
{ mode, displayName: undefined, address: "people-agent@team" },
]))("external $mode attach with displayName=$displayName can receive work at its returned identity", async ({ mode, displayName, address }) => {
const rig = rigs.createRig("team"), pod = pods.createPod(rig.id, "people", "People");
const existing = mode === "node" ? rigs.addNode(rig.id, "people.agent", { runtime: "terminal", podId: pod.id }) : null;
const body = { attachmentType: "external_cli", displayName, runtime: "terminal",
...(mode === "node" ? { logicalId: existing!.logicalId } : { podNamespace: "people", memberName: "agent" }) };
const response = await app.request(`/api/rigs/${rig.id}/attach-self`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
expect(response.status).toBe(201);
const result = await response.json() as { nodeId: string; sessionName: string; senderAddress: string; env: { OPENRIG_SESSION_NAME: string } };
const binding = sessions.getBindingForNode(result.nodeId);
expect(binding).toMatchObject({ attachmentType: "external_cli", externalSessionName: result.sessionName });
const whoami = new WhoamiService({ db, rigRepo: rigs, sessionRegistry: sessions, transcriptStore: new TranscriptStore({ transcriptsRoot: transcriptRoot }) });
expect(whoami.resolve({ nodeId: result.nodeId })?.identity).toMatchObject({ nodeId: result.nodeId, senderAddress: result.env.OPENRIG_SESSION_NAME });
expect(whoami.resolve({ sessionName: result.env.OPENRIG_SESSION_NAME })?.identity.nodeId).toBe(result.nodeId);
const queue = new QueueRepository(db, bus, {
validateRig: makeTopologyDestinationCheck({ findRigsByName: name => rigs.findRigsByName(name), loadHumanRegistry: () => { throw new Error("no human address in this fixture"); } }),
resolveSessionAddresses: name => sessions.sessionAddresses(name),
});
// Use precisely the address attach advertised, with the production gate.
const item = await queue.create({ sourceSession: "operator@team", destinationSession: result.env.OPENRIG_SESSION_NAME, body: "work for attached member", nudge: false });
expect(queue.claim({ qitemId: item.qitemId, destinationSession: result.env.OPENRIG_SESSION_NAME }).destinationSession).toBe(address);
expect(queue.whoami(result.env.OPENRIG_SESSION_NAME).asDestination.inProgress).toBe(1);
expect(result).toMatchObject({ sessionName: address, senderAddress: address, env: { OPENRIG_SESSION_NAME: address } });
// A second attach does not rename a persisted member, even with a new label.
const before = sessions.getSessionsForRig(rig.id);
expect(await attach.attachToNode({ rigId: rig.id, logicalId: "people.agent", displayName: "renamed" })).toMatchObject({ ok: false, code: "already_bound" });
expect(sessions.getBindingForNode(result.nodeId)).toEqual(binding);
expect(sessions.getSessionsForRig(rig.id)).toEqual(before);
});
it.each(["0", "person-terminal"])("returns a routable sender and records the display label without renaming %s", async (physical) => {
const rig = rigs.createRig("sample-team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
const result = await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, displayName: "Taylor", context: context(physical) });