Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ app.use(
correlations,
context: contextEngine,
},
remoteGateway,
),
);
app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore));
Expand Down
93 changes: 91 additions & 2 deletions apps/server/src/remote/remoteEnvironmentGateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { connectorFor, type SubagentInfo } from "@tangent/shared/contracts.ts";
import {
connectorFor,
SocketEvents,
type SubagentInfo,
} from "@tangent/shared/contracts.ts";
import {
type RemoteAgentEventPayload,
RemoteEnvEvents,
Expand Down Expand Up @@ -141,8 +145,14 @@ function makeHarness(options: HarnessOptions = {}) {
const store = new InMemorySessionStore();
const runStore = new InMemoryRunStore();
const runs = new RunRegistry(runStore);
const broadcasts: Array<{ event: string; payload: unknown }> = [];
const io = {
of: () => namespace,
emit: (event: string, payload: unknown) =>
broadcasts.push({ event, payload }),
};
const gateway = new RemoteEnvironmentGateway(
{ of: () => namespace } as unknown as SocketIOServer,
io as unknown as SocketIOServer,
relayHandlers(rosterUpdates, agentEvents),
store,
runs,
Expand All @@ -158,6 +168,7 @@ function makeHarness(options: HarnessOptions = {}) {
runStore,
rosterUpdates,
agentEvents,
broadcasts,
};
}

Expand Down Expand Up @@ -856,3 +867,81 @@ test("an environmentId stays claimed by its owner after they disconnect", () =>
"Alice can still reclaim her own id",
);
});

test("listEnvironments lists a session's scoped hosts and its dev hosts, newest first", () => {
const scoped = new ScopedTokenCredential(SCOPED_SECRET);
const h = makeHarness({ scoped });
const alice = h.connect("x", {
token: mintScoped(scoped, "env-a", "s1", "alice@x"),
});
h.connect("vscode-generic");
h.connect("x", { token: mintScoped(scoped, "env-b", "s2", "bob@x") });

alice.send(RemoteEnvEvents.ToolsRegister, {
sessionId: "s1",
tools: [ECHO_TOOL],
});
h.gateway.spawnSubagent("s1", {
name: "Worker",
audienceParticipantId: "alice@x",
});

assert.deepEqual(h.gateway.listEnvironments("s1"), [
{
environmentId: "vscode-generic",
sessionId: undefined,
owner: undefined,
scoped: false,
toolCount: 0,
subagentCount: 0,
},
{
environmentId: "env-a",
sessionId: "s1",
owner: "alice@x",
scoped: true,
toolCount: 1,
subagentCount: 1,
},
]);
assert.deepEqual(
h.gateway.listEnvironments("s2").map((e) => e.environmentId),
["env-b", "vscode-generic"],
);
});

test("a session-pinned dev host replaces the generic one for its session only", () => {
const h = makeHarness();
h.connect("s1:vscode-1");
h.connect("vscode-generic");

assert.deepEqual(
h.gateway.listEnvironments("s1").map((e) => e.environmentId),
["s1:vscode-1"],
);
assert.deepEqual(
h.gateway.listEnvironments("s2").map((e) => e.environmentId),
["vscode-generic"],
);
});

test("environment changes are broadcast and reflected in the list", async () => {
const h = makeHarness();
const updates = () =>
h.broadcasts.filter((b) => b.event === SocketEvents.EnvironmentUpdate)
.length;

const env = h.connect("env-1");
await flush();
assert.equal(updates(), 1, "connect");

const { info } = h.gateway.spawnSubagent("s1", { name: "Worker" });
assert.equal(h.gateway.listEnvironments("s1")[0].subagentCount, 1);
h.gateway.killAgent("s1", info.id);
assert.equal(h.gateway.listEnvironments("s1")[0].subagentCount, 0);
assert.equal(updates(), 3, "spawn and kill");

env.disconnect();
assert.equal(updates(), 4, "disconnect");
assert.deepEqual(h.gateway.listEnvironments("s1"), []);
});
40 changes: 40 additions & 0 deletions apps/server/src/remote/remoteEnvironmentGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import path from "node:path";

import {
connectorFor,
type EnvironmentUpdatePayload,
isTerminalStatus,
type MessageDelivery,
type RemoteEnvironmentInfo,
RESTORABLE_STATUSES,
type RunId,
type RunIngress,
SocketEvents,
type SubagentInfo,
type SubagentStatus,
} from "@tangent/shared/contracts.ts";
Expand Down Expand Up @@ -244,6 +247,31 @@ export class RemoteEnvironmentGateway {
return [...roster.values()].map(toInfo);
}

/**
* The connected environments `sessionId` can route to: its scoped hosts plus
* the unscoped dev hosts eligible for it, most recent first.
*/
listEnvironments(sessionId: string): RemoteEnvironmentInfo[] {
const roster = [...(this.sessions.get(sessionId)?.values() ?? [])];
return [
...this.scopedEnvironments(sessionId),
...this.unscopedEnvironmentsFor(sessionId),
]
.sort((a, b) => b.seq - a.seq)
.map((environment) => ({
environmentId: environment.environmentId,
sessionId: environment.sessionId,
owner: environment.sub,
scoped: Boolean(environment.sessionId),
toolCount: environment.tools.size,
subagentCount: roster.filter(
(subagent) =>
subagent.environmentId === environment.environmentId &&
!isTerminalStatus(subagent.status),
).length,
}));
}

/**
* Spawns a sub-agent on a connected remote environment. Resolves the
* effective config from the session's installed bundle templates/defaults
Expand Down Expand Up @@ -304,6 +332,7 @@ export class RemoteEnvironmentGateway {

const info = toInfo(subagent);
this.handlers.onSubagentUpdate(sessionId, info);
this.emitEnvironmentsChanged(sessionId);
return {
info,
tools,
Expand Down Expand Up @@ -412,6 +441,13 @@ export class RemoteEnvironmentGateway {
completed,
} satisfies RemoteKillCommand);
this.handlers.onSubagentUpdate(sessionId, toInfo(subagent));
this.emitEnvironmentsChanged(sessionId);
}

/** Tells every client that the connected environments changed. */
private emitEnvironmentsChanged(sessionId?: string): void {
const payload: EnvironmentUpdatePayload = sessionId ? { sessionId } : {};
this.io.emit(SocketEvents.EnvironmentUpdate, payload);
}

/** Emits an event to a specific environment's socket, if still connected. */
Expand Down Expand Up @@ -691,6 +727,7 @@ export class RemoteEnvironmentGateway {
console.log(
`[remote-env] ${environmentId} registered ${environment.tools.size} tool(s)`,
);
this.emitEnvironmentsChanged();
}

/**
Expand Down Expand Up @@ -792,6 +829,7 @@ export class RemoteEnvironmentGateway {
if (!RESTORABLE_STATUSES.includes(agent.status)) continue;
this.reattach(agent.sessionId, agent);
}
this.emitEnvironmentsChanged();
}

/**
Expand Down Expand Up @@ -849,6 +887,7 @@ export class RemoteEnvironmentGateway {
payload.status === "completed" ? "completed" : "failed",
);
this.clearActivity(payload.sessionId, subagent);
this.emitEnvironmentsChanged(payload.sessionId);
}
this.handlers.onSubagentUpdate(payload.sessionId, toInfo(subagent));
}
Expand Down Expand Up @@ -934,6 +973,7 @@ export class RemoteEnvironmentGateway {
console.log(
`[remote-env] disconnected: ${environmentId} session=${dropped?.sessionId ?? "unscoped"} socket=${dropped?.socket.id}`,
);
this.emitEnvironmentsChanged();
}

/**
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/routes/sessions/environments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ListEnvironmentsResponse } from "@tangent/shared/contracts.ts";
import { type Request, type Response, Router } from "express";

import { getValidated, validate } from "../../middleware/validate.ts";
import type { RemoteEnvironmentGateway } from "../../remote/remoteEnvironmentGateway.ts";
import type { SessionStore } from "../../store/sessionStore.ts";
import type { SessionParams } from "./schemas.ts";
import { sessionParamsSchema } from "./schemas.ts";
import { loadSession } from "./utils.ts";

/** The slice of the remote gateway the environments route reads. */
export type EnvironmentLister = Pick<
RemoteEnvironmentGateway,
"listEnvironments"
>;

/** `GET /:id/environments` → the connected remote environments this session can use. */
async function handleListEnvironments(
store: SessionStore,
environments: EnvironmentLister,
id: string,
res: Response,
): Promise<void> {
const session = await loadSession(store, res, id);
if (!session) return;
const body: ListEnvironmentsResponse = {
environments: environments.listEnvironments(session.id),
};
res.json(body);
}

/** Registers the read-only connected-environments route on a session. */
export function registerEnvironmentRoutes(
router: Router,
store: SessionStore,
environments: EnvironmentLister,
): void {
router.get(
"/:id/environments",
validate({ params: sessionParamsSchema }),
(req: Request, res: Response) =>
handleListEnvironments(
store,
environments,
getValidated<unknown, SessionParams>(req).params.id,
res,
),
);
}
6 changes: 6 additions & 0 deletions apps/server/src/routes/sessions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts";
import type { TriggerManager } from "../../pi/triggers/triggerManager.ts";
import type { AgentBundleStore } from "../../store/agentBundleStore.ts";
import type { SessionStore } from "../../store/sessionStore.ts";
import {
type EnvironmentLister,
registerEnvironmentRoutes,
} from "./environments.ts";
import {
createArtifactFileHandler,
handleCreateSession,
Expand Down Expand Up @@ -142,6 +146,7 @@ export function createSessionsRouter(
hostPreamble: HostResourcePreamble,
emitResourcesUpdated: (sessionId: string) => void,
workflow: WorkflowRouteDeps,
environments: EnvironmentLister,
): Router {
const router = Router();

Expand All @@ -161,6 +166,7 @@ export function createSessionsRouter(
registerSessionActivityRoutes(router, store);
registerTriggerRoutes(router, store, triggers, triggerEngine);
registerParticipantRoutes(router, store, participants);
registerEnvironmentRoutes(router, store, environments);
registerResourceRoutes(router, {
store,
resources,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/embed/components/EmbeddedParticipantList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Box } from "@tangent/ui-primitives/box";

import { ParticipantList } from "@/features/chat/components/sidebar/participants/ParticipantList";
import { useSessionChat } from "@/features/chat/hooks/useSessionChat";
import { useSessionEnvironments } from "@/features/chat/hooks/useSessionEnvironments";
import {
useMuteMembership,
useSessionParticipants,
Expand Down Expand Up @@ -35,12 +36,14 @@ export function EmbeddedParticipantList({

const { data: participants = [] } = useSessionParticipants(sessionId);
const muteMembership = useMuteMembership(sessionId);
const { data: environments = [] } = useSessionEnvironments(sessionId);

return (
<ScrollRegion>
<Box inlineSize="full">
<ParticipantList
participants={participants}
environments={environments}
activeConversationId={activeConversationId}
currentUserId={chat.currentAuthorId}
onToggleMute={(participantId, conversationId, muted) => {
Expand Down
Loading
Loading