Skip to content
Open
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
17 changes: 13 additions & 4 deletions apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe("getNotifyScriptContent", () => {
"SESSION_ID=" + "\u0024{RESOURCE_ID:-$HOOK_SESSION_ID}",
);
expect(script).toContain(
'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"',
'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"workspaceId\\":\\"$(json_escape "$SUPERSET_WORKSPACE_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"',
);
expect(script).toContain('--data-urlencode "resourceId=$RESOURCE_ID"');
expect(script).toContain(
Expand All @@ -25,15 +25,24 @@ describe("getNotifyScriptContent", () => {
);
});

it("gives the v2 host-service hook enough time to avoid false fallback", () => {
it("keeps the legacy hook after the v2 host-service hook", () => {
const script = readFileSync(
path.join(import.meta.dir, "templates", "notify-hook.template.sh"),
"utf-8",
);

expect(script).toContain(
'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \\\n --connect-timeout 2 --max-time 5',
const hostServiceCurlIndex = script.indexOf(
'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL"',
);
const legacyCurlIndex = script.indexOf(
'curl -sG "http://127.0.0.1:' +
"$" +
'{SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete"',
);

expect(hostServiceCurlIndex).toBeGreaterThanOrEqual(0);
expect(legacyCurlIndex).toBeGreaterThanOrEqual(0);
expect(hostServiceCurlIndex).toBeLessThan(legacyCurlIndex);
});

it("keeps the legacy v1 fallback path when no host-service hook URL exists", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,10 @@ json_escape() {
# v2: host-service tRPC endpoint. The renderer subscribes over the event
# bus and plays the ringtone. Preferred when the URL is provided by
# host-service's terminal env. Endpoint is unauthenticated — it only
# broadcasts chimes, no auth header needed. Always captures the status
# so we can fall back to v1 when host-service is unreachable or the
# mutation returns non-2xx (restarts, crashes, transient errors).
# broadcasts chimes, no auth header needed. We still continue to the legacy
# desktop hook below so existing v1 listeners also receive lifecycle events.
if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then
PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}"
PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"workspaceId\":\"$(json_escape "$SUPERSET_WORKSPACE_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}"

STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \
--connect-timeout 2 --max-time 5 \
Expand All @@ -100,14 +99,17 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then
if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2
fi
fi

case "$STATUS_CODE" in
2*) exit 0 ;;
esac
# Pure v2 terminals only have terminal/workspace ids, so host-service is the
# notification path. Continue to the legacy desktop hook only when the shell
# also has legacy pane/tab identity; otherwise v2 users would get duplicates.
if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -z "$SUPERSET_PANE_ID" ] && [ -z "$SUPERSET_TAB_ID" ]; then
exit 0
fi

# v1 fallback: electron localhost server. Used by v1 terminals and when
# host-service is unreachable from the agent's shell.
# v1/legacy: electron localhost server. Used by v1 terminals and by hybrid
# environments that still expose legacy pane/tab identity.
# Timeouts prevent blocking agent completion if notification server is unresponsive
if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,26 @@ describe("notificationsRouter.hook", () => {
expect(unknownTerminal.broadcastAgentLifecycle).not.toHaveBeenCalled();
});

it("falls back to payload workspaceId when the terminal row is not visible yet", async () => {
const { ctx, broadcastAgentLifecycle, findFirst } = createContext(null);
const caller = notificationsRouter.createCaller(ctx);

const result = await caller.hook({
terminalId: "terminal-1",
workspaceId: "workspace-1",
eventType: "Stop",
});

expect(result).toEqual({ success: true, ignored: false });
expect(findFirst).toHaveBeenCalledTimes(1);
expect(broadcastAgentLifecycle).toHaveBeenCalledTimes(1);
expect(broadcastAgentLifecycle.mock.calls[0]?.[0]).toMatchObject({
workspaceId: "workspace-1",
eventType: "Stop",
terminalId: "terminal-1",
});
});

it("ignores unknown event types before looking up the terminal", async () => {
const { ctx, broadcastAgentLifecycle, findFirst } =
createContext("workspace-1");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import { mapEventType } from "../../../events";
import { publicProcedure, router } from "../../index";

/**
* v2 terminal hook payload. The shell hook sends only stable runtime identity;
* host-service derives workspace identity from its terminal session table.
* v2 terminal hook payload. The shell hook sends stable runtime identity.
* Host-service prefers the terminal session table for workspace identity, but
* falls back to the terminal env's workspaceId so lifecycle events are not lost
* if a hook arrives while a new workspace/terminal is still settling.
*/
const hookInput = z.object({
terminalId: z.string().optional(),
workspaceId: z.string().optional(),
eventType: z.string().optional(),
});

Expand Down Expand Up @@ -44,12 +47,13 @@ export const notificationsRouter = router({
columns: { originWorkspaceId: true },
})
.sync();
if (!terminalSession?.originWorkspaceId) {
const workspaceId = terminalSession?.originWorkspaceId ?? input.workspaceId;
if (!workspaceId) {
return { success: true, ignored: true as const };
}

ctx.eventBus.broadcastAgentLifecycle({
workspaceId: terminalSession.originWorkspaceId,
workspaceId,
eventType,
terminalId: input.terminalId,
occurredAt: Date.now(),
Expand Down