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
531 changes: 384 additions & 147 deletions crates/agent-gateway/internal/proto/v2/gateway.pb.go

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error
return vetChatFileOpen(payload.ChatFileOpen)
case *gatewayv2.GatewayEnvelope_WorkspaceRootGrants:
return vetWorkspaceRootGrants(payload.WorkspaceRootGrants)
case *gatewayv2.GatewayEnvelope_Checkpoint:
return vetCheckpoint(payload.Checkpoint)

// ---- 带功能门控 / 限额的直通臂 ----
case *gatewayv2.GatewayEnvelope_GitRequest:
Expand Down Expand Up @@ -111,6 +113,45 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error
}
}

func vetCheckpoint(req *gatewayv2.CheckpointRequest) error {
if req == nil || strings.TrimSpace(req.GetConversationId()) == "" || len(req.GetConversationId()) > 256 {
return errors.New("checkpoint conversation_id is invalid")
}
switch strings.TrimSpace(req.GetAction()) {
case "list":
if req.GetTurnSeq() != 0 || len(req.GetAuthorizedRoots()) != 0 || len(req.GetExpected()) != 0 {
return errors.New("checkpoint list payload is invalid")
}
case "diff":
if req.GetTurnSeq() == 0 || len(req.GetExpected()) != 0 {
return errors.New("checkpoint diff payload is invalid")
}
case "rewind":
if req.GetTurnSeq() == 0 {
return errors.New("checkpoint rewind turn_seq is required")
}
default:
return errors.New("checkpoint action is invalid")
}
if len(req.GetAuthorizedRoots()) > 64 {
return errors.New("too many checkpoint authorized roots")
}
for _, root := range req.GetAuthorizedRoots() {
if strings.TrimSpace(root) == "" || len(root) > 32768 {
return errors.New("checkpoint authorized root is invalid")
}
}
if len(req.GetExpected()) > 10_000 {
return errors.New("too many checkpoint expected entries")
}
for _, entry := range req.GetExpected() {
if entry == nil || strings.TrimSpace(entry.GetKey()) == "" || len(entry.GetKey()) > 65536 || len(entry.GetCurrentHash()) > 256 {
return errors.New("checkpoint expected entry is invalid")
}
}
return nil
}

func vetWorkspaceRootGrants(req *gatewayv2.WorkspaceRootGrantsRequest) error {
if req == nil {
return errors.New("workspace root grants request is required")
Expand Down
54 changes: 54 additions & 0 deletions crates/agent-gateway/internal/protocol/pbws/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,57 @@ func TestVetAgentRequestRejectsMalformedWorkspaceRootGrants(t *testing.T) {
}
}
}

func TestVetAgentRequestAllowsCheckpointActions(t *testing.T) {
requests := []*gatewayv2.CheckpointRequest{
{Action: "list", ConversationId: "conversation-1"},
{Action: "diff", ConversationId: "conversation-1", TurnSeq: 2, AuthorizedRoots: []string{"/work"}},
{
Action: "rewind",
ConversationId: "conversation-1",
TurnSeq: 2,
AuthorizedRoots: []string{"/work"},
Expected: []*gatewayv2.CheckpointExpectedEntry{{Key: "/work\x01a.txt", CurrentHash: "abc"}},
},
}

for _, request := range requests {
env := &gatewayv2.GatewayEnvelope{
Payload: &gatewayv2.GatewayEnvelope_Checkpoint{Checkpoint: request},
}
if err := vetAgentRequest(session.AgentView{}, env); err != nil {
t.Fatalf("vetAgentRequest(%+v) error = %v", request, err)
}
}
}

func TestVetAgentRequestRejectsMalformedCheckpoint(t *testing.T) {
tooManyRoots := make([]string, 65)
for index := range tooManyRoots {
tooManyRoots[index] = "/work"
}
requests := []*gatewayv2.CheckpointRequest{
nil,
{Action: "list", ConversationId: "conversation-1", TurnSeq: 1},
{Action: "diff", ConversationId: "conversation-1"},
{Action: "rewind", ConversationId: "conversation-1"},
{Action: "unknown", ConversationId: "conversation-1"},
{Action: "diff", ConversationId: "conversation-1", TurnSeq: 1, AuthorizedRoots: []string{" "}},
{Action: "diff", ConversationId: "conversation-1", TurnSeq: 1, AuthorizedRoots: tooManyRoots},
{
Action: "rewind",
ConversationId: "conversation-1",
TurnSeq: 1,
Expected: []*gatewayv2.CheckpointExpectedEntry{{Key: " ", CurrentHash: "abc"}},
},
}

for _, request := range requests {
env := &gatewayv2.GatewayEnvelope{
Payload: &gatewayv2.GatewayEnvelope_Checkpoint{Checkpoint: request},
}
if err := vetAgentRequest(session.AgentView{}, env); err == nil {
t.Fatalf("vetAgentRequest(%+v) unexpectedly succeeded", request)
}
}
}
20 changes: 20 additions & 0 deletions crates/agent-gateway/proto/v2/gateway.proto
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ message GatewayEnvelope {
HistorySetCwdRequest history_set_cwd = 95;
WorkspaceRootGrantsRequest workspace_root_grants = 96;
ImportDirectoryRequest import_directory = 97;
CheckpointRequest checkpoint = 98;
}

// Legacy tunnel control/frame payloads (pre-rewrite protocol) and the
Expand Down Expand Up @@ -141,6 +142,7 @@ message AgentEnvelope {
HistorySetCwdResponse history_set_cwd_resp = 100;
WorkspaceRootGrantsResponse workspace_root_grants_resp = 101;
ImportDirectoryResponse import_directory_resp = 102;
CheckpointResponse checkpoint_resp = 103;
}

// Legacy tunnel control/frame payloads (pre-rewrite protocol) and the
Expand Down Expand Up @@ -1385,3 +1387,21 @@ message WorkspaceRootGrantsRequest {
message WorkspaceRootGrantsResponse {
repeated WorkspaceRootGrant grants = 1;
}

message CheckpointExpectedEntry {
string key = 1;
string current_hash = 2;
}

message CheckpointRequest {
string action = 1; // "list" | "diff" | "rewind"
string conversation_id = 2;
uint64 turn_seq = 3;
repeated string authorized_roots = 4;
repeated CheckpointExpectedEntry expected = 5;
}

message CheckpointResponse {
string action = 1;
string result_json = 2;
}
128 changes: 92 additions & 36 deletions crates/agent-gateway/web/src/app/GatewayAppView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import { RightDockPanel } from "@liveagent/ui/components/project-tools/RightDock
import { ScrollArea } from "@liveagent/ui/components/ui/scroll-area";
import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/WorkspaceOverlayHost";
import { LocaleContext, t as translate } from "@liveagent/ui/i18n/index";
import {
type CheckpointRewindClient,
CheckpointRewindProvider,
type CheckpointRewoundInfo,
formatCheckpointRewoundNotification,
} from "@liveagent/ui/lib/chat/checkpointRewind";
import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles";
import { mergePendingUploadedFiles } from "@liveagent/ui/lib/chat/uploadedFiles";
import { ChatComposerBar } from "@liveagent/ui/pages/chat/ChatComposerBar";
Expand All @@ -25,7 +31,7 @@ import {
TranscriptWidthControls,
} from "@liveagent/ui/pages/chat/transcript/TranscriptWidthControls";
import { SettingsPage } from "@liveagent/ui/pages/settings/SettingsPage";
import { type CSSProperties, useCallback } from "react";
import { type CSSProperties, useCallback, useMemo } from "react";
import { GatewayTranscript } from "@/components/GatewayTranscript";
import {
getNextTheme,
Expand Down Expand Up @@ -305,6 +311,48 @@ export function GatewayAppView({ viewModel }: { viewModel: GatewayAppViewModel }
setSettings((prev) => updateExecutionModeFromChatSelection(prev, mode)),
[setSettings],
);
const resolveCheckpointAuthorizedRoots = useCallback(async () => {
const roots: string[] = [];
const push = (value?: string | null) => {
const normalized = value?.trim();
if (normalized && !roots.includes(normalized)) roots.push(normalized);
};
push(displayedConversationWorkdir);
if (
activeWorkspaceProject &&
activeWorkspaceProjectPath &&
activeWorkspaceProjectPath === displayedConversationWorkdir
) {
try {
const grants = await api.listWorkspaceRootGrants(
activeWorkspaceProject.id,
activeWorkspaceProject.path,
);
for (const grant of grants) {
if (grant.state === "active" && grant.access === "write") push(grant.canonicalPath);
}
} catch {
// Keep the primary root when additional grant lookup fails.
}
}
return roots;
}, [activeWorkspaceProject, activeWorkspaceProjectPath, api, displayedConversationWorkdir]);
const checkpointClient = useMemo<CheckpointRewindClient>(
() => ({
list: (conversationId) => api.listCheckpointTurns(conversationId),
preview: (params) => api.previewCheckpointRewind(params),
rewind: (params) => api.rewindCheckpoint(params),
}),
[api],
);

const handleCheckpointRewound = useCallback(
(info: CheckpointRewoundInfo) => {
const notice = formatCheckpointRewoundNotification(info, settings.locale === "zh-CN");
addNotify(notice.level, notice.message);
},
[addNotify, settings.locale],
);
return (
<LocaleContext.Provider value={localeContextValue}>
<AppErrorBoundary>
Expand Down Expand Up @@ -532,42 +580,50 @@ export function GatewayAppView({ viewModel }: { viewModel: GatewayAppViewModel }
className="gateway-transcript-scroll"
>
<ChangedFilesActionsProvider value={changedFilesActions}>
<GatewayTranscript
<CheckpointRewindProvider
client={checkpointClient}
conversationId={displayedConversationId}
rows={transcriptRows}
liveStartIndex={transcriptLiveStartIndex}
activeTurnKey={displayedTranscript.activeTurnKey}
contentWidth={settings.customSettings.chatTranscript.width}
isViewportFollowing={transcriptFollow.isFollowing}
viewportFollowing={transcriptFollowing}
navRef={transcriptNavRef}
onAnchorUserRowChange={setActiveFloorKey}
error={transcriptError}
toolStatus={transcriptToolStatus}
toolStatusIsCompaction={transcriptToolStatusIsCompaction}
retryAttempts={displayedTranscript.retryAttempts}
isStreaming={transcriptBusy}
isLoading={transcriptHistoryLoading}
loadingTitle={historyDetailLoadingTitle}
hasModels={modelOptions.length > 0}
onOpenSettings={openSettings}
hasMoreHistory={selectedHistoryHasMore}
isLoadingMoreHistory={loadingOlderHistory}
onLoadEarlierHistory={
selectedHistoryHasMore ? handleLoadEarlierHistory : undefined
}
showUsage={isAgentDevExecutionMode}
usageContextWindow={currentModelContextWindow}
workspaceRoot={displayedConversationWorkdir}
onOpenFileLink={handleOpenChatFileLink}
gitClient={gitClient}
onLoadUploadedImagePreview={handleLoadUploadedImagePreview}
onResendFromEdit={handleResendFromEdit}
onBranchConversation={handleBranchConversation}
branchPendingMessageId={branchPendingMessageId}
onSuggestionSelect={handleEmptyStateSuggestion}
suggestionsDisabled={isSuggestionTyping}
/>
disabled={!displayedConversationId || transcriptBusy}
resolveAuthorizedRoots={resolveCheckpointAuthorizedRoots}
onRewound={handleCheckpointRewound}
>
<GatewayTranscript
conversationId={displayedConversationId}
rows={transcriptRows}
liveStartIndex={transcriptLiveStartIndex}
activeTurnKey={displayedTranscript.activeTurnKey}
contentWidth={settings.customSettings.chatTranscript.width}
isViewportFollowing={transcriptFollow.isFollowing}
viewportFollowing={transcriptFollowing}
navRef={transcriptNavRef}
onAnchorUserRowChange={setActiveFloorKey}
error={transcriptError}
toolStatus={transcriptToolStatus}
toolStatusIsCompaction={transcriptToolStatusIsCompaction}
retryAttempts={displayedTranscript.retryAttempts}
isStreaming={transcriptBusy}
isLoading={transcriptHistoryLoading}
loadingTitle={historyDetailLoadingTitle}
hasModels={modelOptions.length > 0}
onOpenSettings={openSettings}
hasMoreHistory={selectedHistoryHasMore}
isLoadingMoreHistory={loadingOlderHistory}
onLoadEarlierHistory={
selectedHistoryHasMore ? handleLoadEarlierHistory : undefined
}
showUsage={isAgentDevExecutionMode}
usageContextWindow={currentModelContextWindow}
workspaceRoot={displayedConversationWorkdir}
onOpenFileLink={handleOpenChatFileLink}
gitClient={gitClient}
onLoadUploadedImagePreview={handleLoadUploadedImagePreview}
onResendFromEdit={handleResendFromEdit}
onBranchConversation={handleBranchConversation}
branchPendingMessageId={branchPendingMessageId}
onSuggestionSelect={handleEmptyStateSuggestion}
suggestionsDisabled={isSuggestionTyping}
/>
</CheckpointRewindProvider>
</ChangedFilesActionsProvider>
</ScrollArea>
<TranscriptWidthControls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ const GatewayUserMessageRowBody = memo(function GatewayUserMessageRowBody(props:
onEdit={() => {
if (effectiveMessageRef) setEditingMessageId(row.key);
}}
rewindTurnId={effectiveMessageRef?.messageId}
readOnly={readOnly}
alwaysShowActions
/>
Expand Down
11 changes: 11 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ import type {
ChatCommandUpdateListener,
ChatFileOpenResponse,
ChatQueueListener,
CheckpointDiffStats,
CheckpointRewindClient,
CheckpointRewindResult,
CheckpointTurnSummary,
FsCreateDirResponse,
FsDeleteResponse,
FsListDirsResponse,
Expand Down Expand Up @@ -341,6 +345,13 @@ export type GatewayWebSocketClientLike = {
projectId: string,
projectPath: string,
): Promise<GatewayWorkspaceRootGrant[]>;
listCheckpointTurns(conversationId: string): Promise<CheckpointTurnSummary[]>;
previewCheckpointRewind(
params: Parameters<CheckpointRewindClient["preview"]>[0],
): Promise<CheckpointDiffStats>;
rewindCheckpoint(
params: Parameters<CheckpointRewindClient["rewind"]>[0],
): Promise<CheckpointRewindResult>;
applyWorkspaceRootGrants(
projectId: string,
projectPath: string,
Expand Down
35 changes: 35 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocketRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ import {
type ChatCommandUpdateListener,
type ChatFileOpenResponse,
type ChatQueueListener,
type CheckpointDiffStats,
type CheckpointRewindResult,
type CheckpointTurnSummary,
type FsCreateDirResponse,
type FsDeleteResponse,
type FsListDirsResponse,
Expand Down Expand Up @@ -1112,6 +1115,38 @@ export class GatewayWebSocketRpcClient extends GatewayWebSocketTransport {
});
}

async listCheckpointTurns(conversationId: string): Promise<CheckpointTurnSummary[]> {
return this.requestWithRecovery<CheckpointTurnSummary[]>("checkpoint.list", {
conversation_id: conversationId,
});
}

async previewCheckpointRewind(params: {
conversationId: string;
turnSeq: number;
authorizedRoots: string[];
}): Promise<CheckpointDiffStats> {
return this.request<CheckpointDiffStats>("checkpoint.diff", {
conversation_id: params.conversationId,
turn_seq: params.turnSeq,
authorized_roots: params.authorizedRoots,
});
}

async rewindCheckpoint(params: {
conversationId: string;
turnSeq: number;
authorizedRoots: string[];
expected: { key: string; currentHash: string }[];
}): Promise<CheckpointRewindResult> {
return this.request<CheckpointRewindResult>("checkpoint.rewind", {
conversation_id: params.conversationId,
turn_seq: params.turnSeq,
authorized_roots: params.authorizedRoots,
expected: params.expected,
});
}

async listDirs(path: string, maxResults?: number): Promise<FsListDirsResponse> {
return this.requestWithRecovery<FsListDirsResponse>("fs.list_dirs", {
path,
Expand Down
Loading
Loading