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
111 changes: 107 additions & 4 deletions apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { NOTIFY_SCRIPT_MARKER } from "./notify-hook";
import { getNotifyScriptContent, NOTIFY_SCRIPT_MARKER } from "./notify-hook";

describe("getNotifyScriptContent", () => {
it("bumps the notify hook marker when hook semantics change", () => {
expect(NOTIFY_SCRIPT_MARKER).toBe("# Superset agent notification hook v3");
expect(NOTIFY_SCRIPT_MARKER).toBe("# Superset agent notification hook v4");
});

it("emits the v2 host-service payload with full agent identity", () => {
Expand Down Expand Up @@ -36,6 +38,107 @@ describe("getNotifyScriptContent", () => {
);
});

it("emits hidden UserPromptSubmit context when workspace fan-out is enabled", () => {
const result = spawnSync(
"bash",
["-s", "--", '{"hook_event_name":"UserPromptSubmit"}'],
{
input: getNotifyScriptContent(),
encoding: "utf8",
env: {
PATH: process.env.PATH,
SUPERSET_AGENT_DELEGATION_MODE: "workspaces",
SUPERSET_AGENT_ID: "codex",
SUPERSET_WORKSPACE_ID: "workspace-123",
},
},
);

expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
expect(output).toMatchObject({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
},
});
expect(output.hookSpecificOutput.additionalContext).toContain(
"superset workspaces create-subworkspace",
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'--parent "workspace-123"',
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'--agent "codex"',
);
});

it("does not emit delegation context in native mode", () => {
const result = spawnSync(
"bash",
["-s", "--", '{"hook_event_name":"UserPromptSubmit"}'],
{
input: getNotifyScriptContent(),
encoding: "utf8",
env: {
PATH: process.env.PATH,
SUPERSET_AGENT_DELEGATION_MODE: "native",
SUPERSET_AGENT_ID: "codex",
SUPERSET_WORKSPACE_ID: "workspace-123",
},
},
);

expect(result.status).toBe(0);
expect(result.stdout).toBe("");
});

it("uses the host's current mode for an already-open terminal", () => {
const fakeBinDir = mkdtempSync(
path.join(tmpdir(), "superset-notify-hook-test-"),
);
try {
writeFileSync(
path.join(fakeBinDir, "curl"),
`#!/bin/bash
while [ "$#" -gt 0 ]; do
case "$1" in
-o) OUTPUT_FILE="$2"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' '{"result":{"data":{"json":{"agentDelegationMode":"workspaces"}}}}' > "$OUTPUT_FILE"
printf '200'
`,
{ mode: 0o755 },
);

const result = spawnSync(
"bash",
["-s", "--", '{"hook_event_name":"UserPromptSubmit"}'],
{
input: getNotifyScriptContent(),
encoding: "utf8",
env: {
PATH: `${fakeBinDir}:${process.env.PATH}`,
SUPERSET_AGENT_DELEGATION_MODE: "native",
SUPERSET_AGENT_ID: "codex",
SUPERSET_HOST_AGENT_HOOK_URL: "http://127.0.0.1/hook",
SUPERSET_TERMINAL_ID: "terminal-123",
SUPERSET_WORKSPACE_ID: "workspace-123",
},
},
);

expect(result.status).toBe(0);
const output = JSON.parse(result.stdout);
expect(output.hookSpecificOutput.additionalContext).toContain(
'--parent "workspace-123"',
);
} finally {
rmSync(fakeBinDir, { recursive: true, force: true });
}
});

it("falls back to the v1 Electron hook when v2 is unavailable", () => {
const script = readFileSync(
path.join(import.meta.dir, "templates", "notify-hook.template.sh"),
Expand All @@ -46,7 +149,7 @@ describe("getNotifyScriptContent", () => {
'if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then',
);
expect(script).toContain(
'[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0',
'if [ -n "$SUPERSET_TAB_ID" ] || [ -n "$SESSION_ID" ] || [ -n "$SUPERSET_TERMINAL_ID" ]; then',
);
expect(script).toContain("/hook/complete");
expect(script).toContain("terminalId=$SUPERSET_TERMINAL_ID");
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/lib/agent-setup/notify-hook.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { buildWorkspaceDelegationInstructions } from "@superset/shared/agent-delegation";
import { env } from "shared/env.shared";
import { HOOKS_DIR } from "./paths";

export const NOTIFY_SCRIPT_NAME = "notify.sh";
export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook v3";
export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook v4";

const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join(
__dirname,
Expand Down Expand Up @@ -39,8 +40,13 @@ export function getNotifyScriptPath(): string {

export function getNotifyScriptContent(): string {
const template = fs.readFileSync(NOTIFY_SCRIPT_TEMPLATE_PATH, "utf-8");
const delegationContext = buildWorkspaceDelegationInstructions({
workspaceId: "__SUPERSET_WORKSPACE_ID__",
agent: "__SUPERSET_AGENT_ID__",
});
return template
.replaceAll("{{MARKER}}", NOTIFY_SCRIPT_MARKER)
.replaceAll("{{DELEGATION_CONTEXT}}", delegationContext)
.replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ if [ -z "$EVENT_TYPE" ]; then
esac
fi

RAW_EVENT_TYPE="$EVENT_TYPE"

# UserPromptSubmit normalizes here; other aliases are mapped server-side
# by mapEventType so the wire stays a single source of truth.
[ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start"
Expand Down Expand Up @@ -70,62 +72,90 @@ case "$V1_EVENT_TYPE" in
esac

json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
local value="$1"
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//$'\n'/\\n}
value=${value//$'\r'/\\r}
value=${value//$'\t'/\\t}
printf '%s' "$value"
}

DELEGATION_MODE="${SUPERSET_AGENT_DELEGATION_MODE:-native}"

if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then
PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\"}}}"
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/superset-agent-hook.XXXXXX")

STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \
--connect-timeout 2 --max-time 5 \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
-o /dev/null -w "%{http_code}" 2>/dev/null)
-o "$RESPONSE_FILE" -w "%{http_code}" 2>/dev/null)
RESPONSE=$(cat "$RESPONSE_FILE" 2>/dev/null)
rm -f "$RESPONSE_FILE"
HOST_DELEGATION_MODE=$(printf '%s' "$RESPONSE" | grep -oE '"agentDelegationMode"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"')
if [ -n "$HOST_DELEGATION_MODE" ]; then
DELEGATION_MODE="$HOST_DELEGATION_MODE"
fi

if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2
echo "[notify-hook] host-service dispatched status=$STATUS_CODE delegationMode=$DELEGATION_MODE" >&2
fi
debug_log "host-service status=$STATUS_CODE url=$SUPERSET_HOST_AGENT_HOOK_URL"

case "$STATUS_CODE" in
2*) exit 0 ;;
2*) HOST_DISPATCHED="1" ;;
esac
fi

# v1 fallback: Electron localhost hook server. Kept while v1 terminals exist.
[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0
if [ "${HOST_DISPATCHED:-0}" != "1" ]; then
if [ -n "$SUPERSET_TAB_ID" ] || [ -n "$SESSION_ID" ] || [ -n "$SUPERSET_TERMINAL_ID" ]; then

if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \
--connect-timeout 1 --max-time 2 \
--data-urlencode "paneId=$SUPERSET_PANE_ID" \
--data-urlencode "tabId=$SUPERSET_TAB_ID" \
--data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \
--data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \
--data-urlencode "sessionId=$SESSION_ID" \
--data-urlencode "hookSessionId=$HOOK_SESSION_ID" \
--data-urlencode "resourceId=$RESOURCE_ID" \
--data-urlencode "eventType=$V1_EVENT_TYPE" \
--data-urlencode "env=$SUPERSET_ENV" \
--data-urlencode "version=$SUPERSET_HOOK_VERSION" \
-o /dev/null -w "%{http_code}" 2>/dev/null)
echo "[notify-hook] v1 dispatched status=$STATUS_CODE" >&2
debug_log "v1 status=$STATUS_CODE port=${SUPERSET_PORT:-{{DEFAULT_PORT}}}"
else
debug_log "v1 dispatch port=${SUPERSET_PORT:-{{DEFAULT_PORT}}}"
curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \
--connect-timeout 1 --max-time 2 \
--data-urlencode "paneId=$SUPERSET_PANE_ID" \
--data-urlencode "tabId=$SUPERSET_TAB_ID" \
--data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \
--data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \
--data-urlencode "sessionId=$SESSION_ID" \
--data-urlencode "hookSessionId=$HOOK_SESSION_ID" \
--data-urlencode "resourceId=$RESOURCE_ID" \
--data-urlencode "eventType=$V1_EVENT_TYPE" \
--data-urlencode "env=$SUPERSET_ENV" \
--data-urlencode "version=$SUPERSET_HOOK_VERSION" \
> /dev/null 2>&1
fi
fi
fi

if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \
--connect-timeout 1 --max-time 2 \
--data-urlencode "paneId=$SUPERSET_PANE_ID" \
--data-urlencode "tabId=$SUPERSET_TAB_ID" \
--data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \
--data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \
--data-urlencode "sessionId=$SESSION_ID" \
--data-urlencode "hookSessionId=$HOOK_SESSION_ID" \
--data-urlencode "resourceId=$RESOURCE_ID" \
--data-urlencode "eventType=$V1_EVENT_TYPE" \
--data-urlencode "env=$SUPERSET_ENV" \
--data-urlencode "version=$SUPERSET_HOOK_VERSION" \
-o /dev/null -w "%{http_code}" 2>/dev/null)
echo "[notify-hook] v1 dispatched status=$STATUS_CODE" >&2
debug_log "v1 status=$STATUS_CODE port=${SUPERSET_PORT:-{{DEFAULT_PORT}}}"
else
debug_log "v1 dispatch port=${SUPERSET_PORT:-{{DEFAULT_PORT}}}"
curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \
--connect-timeout 1 --max-time 2 \
--data-urlencode "paneId=$SUPERSET_PANE_ID" \
--data-urlencode "tabId=$SUPERSET_TAB_ID" \
--data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \
--data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \
--data-urlencode "sessionId=$SESSION_ID" \
--data-urlencode "hookSessionId=$HOOK_SESSION_ID" \
--data-urlencode "resourceId=$RESOURCE_ID" \
--data-urlencode "eventType=$V1_EVENT_TYPE" \
--data-urlencode "env=$SUPERSET_ENV" \
--data-urlencode "version=$SUPERSET_HOOK_VERSION" \
> /dev/null 2>&1
if [ "$RAW_EVENT_TYPE" = "UserPromptSubmit" ] && [ "$DELEGATION_MODE" = "workspaces" ]; then
DELEGATION_CONTEXT=$(cat <<'SUPERSET_DELEGATION_CONTEXT'
{{DELEGATION_CONTEXT}}
SUPERSET_DELEGATION_CONTEXT
)
DELEGATION_CONTEXT=${DELEGATION_CONTEXT//__SUPERSET_WORKSPACE_ID__/$SUPERSET_WORKSPACE_ID}
DELEGATION_CONTEXT=${DELEGATION_CONTEXT//__SUPERSET_AGENT_ID__/$SUPERSET_AGENT_ID}
printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$(json_escape "$DELEGATION_CONTEXT")"
fi

exit 0
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/commandPalette/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface CommandContext {
id: string;
name: string;
projectId?: string;
workspaceType?: "main" | "worktree";
workspaceType?: "main" | "worktree" | "subworkspace";
hostId?: string;
preferredOpenInApp?: ExternalApp;
} | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { useHostWorkspacesSource } from "./useHostWorkspaces";
export type {
HostWorkspaceItem,
HostWorkspaceRow,
HostWorkspaceType,
} from "./useHostWorkspaces.utils";
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SelectV2Workspace } from "@superset/db/schema";
import type { AgentDelegationMode } from "@superset/shared/agent-delegation";
import { buildHostRoutingKey } from "@superset/shared/host-routing";
import type { WorkspaceSnapshotPayload } from "@superset/workspace-client";
import { del as idbDel, get as idbGet, set as idbSet } from "idb-keyval";
Expand All @@ -7,13 +8,21 @@ import { del as idbDel, get as idbGet, set as idbSet } from "idb-keyval";
* A workspace row as served by a host (`workspace.list`) — the cloud row
* shape plus the host-only extras.
*/
export interface HostWorkspaceRow extends SelectV2Workspace {
export type HostWorkspaceType = "main" | "worktree" | "subworkspace";

export interface HostWorkspaceRow extends Omit<SelectV2Workspace, "type"> {
type: HostWorkspaceType;
parentWorkspaceId: string | null;
agentDelegationMode: AgentDelegationMode;
worktreePath: string;
worktreeExists: boolean;
}

/** Merged item returned by useHostWorkspaces. */
export interface HostWorkspaceItem extends SelectV2Workspace {
export interface HostWorkspaceItem extends Omit<SelectV2Workspace, "type"> {
type: HostWorkspaceType;
parentWorkspaceId: string | null;
agentDelegationMode: AgentDelegationMode;
worktreePath?: string;
worktreeExists?: boolean;
/** False when the row came from a snapshot/cloud and the host didn't answer. */
Expand Down Expand Up @@ -170,6 +179,8 @@ export function applyWorkspaceChangedEvent(
name: snapshot.name,
branch: snapshot.branch,
type: snapshot.type,
parentWorkspaceId: snapshot.parentWorkspaceId,
agentDelegationMode: snapshot.agentDelegationMode,
createdByUserId: snapshot.createdByUserId,
taskId: snapshot.taskId,
createdAt: new Date(snapshot.createdAt),
Expand Down Expand Up @@ -215,6 +226,8 @@ export function mergeHostWorkspaces({
seenIds.add(row.id);
items.push({
...row,
parentWorkspaceId: row.parentWorkspaceId ?? null,
agentDelegationMode: row.agentDelegationMode ?? "native",
hostReachable: result.reachable,
source: "host",
});
Expand All @@ -226,6 +239,8 @@ export function mergeHostWorkspaces({
seenIds.add(row.id);
items.push({
...row,
parentWorkspaceId: null,
agentDelegationMode: "native",
hostReachable: false,
source: "cloud",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function DashboardSidebarDeleteDialog({
}: DashboardSidebarDeleteDialogProps) {
const {
deleteBranch,
isSubworkspace,
setDeleteBranch,
hasChanges,
hasUnpushedCommits,
Expand Down Expand Up @@ -61,6 +62,7 @@ export function DashboardSidebarDeleteDialog({
open={open}
onOpenChange={handleOpenChange}
workspaceName={workspaceName}
isSubworkspace={isSubworkspace}
deleteBranch={deleteBranch}
onDeleteBranchChange={setDeleteBranch}
hasChanges={hasChanges}
Expand Down
Loading