From f2446a59d3840134b6c4508c767ac7545db66b1e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 20:38:59 -0700 Subject: [PATCH 01/11] fix(dispatch): keep remote sessions visible and session-scoped --- .../sections/sessions/SessionsSection.scss | 28 ------ .../sections/sessions/SessionsSection.tsx | 81 ++---------------- src/web-ui/src/features/dispatch/README.md | 6 ++ .../dispatch/dispatch.contract.test.ts | 16 ++++ .../dispatch/optimisticDispatchTurn.ts | 29 +++++++ .../EventHandlerModule.test.ts | 85 +++++++++++++++++++ .../flow-chat-manager/EventHandlerModule.ts | 58 ++++++++++++- .../flow-chat-manager/MessageModule.test.ts | 67 +++++++++++++-- .../flow-chat-manager/MessageModule.ts | 33 +++++++ src/web-ui/src/locales/en-US/common.json | 4 - src/web-ui/src/locales/zh-CN/common.json | 4 - src/web-ui/src/locales/zh-TW/common.json | 4 - 12 files changed, 295 insertions(+), 120 deletions(-) create mode 100644 src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index c379adeacf..b0831c1a2d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -21,34 +21,6 @@ margin: 0 $size-gap-1 0 calc(#{$size-gap-1} + 4px); } - &__session-target-filter { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: $size-gap-1; - min-width: 0; - padding: 2px $size-gap-1 4px; - color: var(--color-text-muted); - font-size: var(--font-size-2xs); - - select { - min-width: 0; - height: 22px; - padding: 0 20px 0 6px; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-sm; - background: var(--element-bg-soft); - color: var(--color-text-secondary); - font: inherit; - text-overflow: ellipsis; - - &:focus-visible { - border-color: var(--color-accent-500); - outline: 1px solid var(--color-accent-500); - } - } - } - &__inline-action { display: flex; align-items: center; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 55a40f58b5..87f8693ec7 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -107,13 +107,6 @@ const resolveSessionModeType = (session: Session): SessionMode => { const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); -const dispatchFilterKey = (session: Session): string => { - const target = session.config.dispatchTarget; - if (target?.kind === 'ssh') return `ssh:${target.connectionId}`; - if (target?.kind === 'device') return `device:${target.deviceId}`; - return 'local'; -}; - const countTopLevelSessionsInScope = ( sessions: Iterable, workspacePath?: string, @@ -209,7 +202,6 @@ const SessionsSection: React.FC = ({ const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [expandLevel, setExpandLevel] = useState<0 | 1 | 2>(0); - const [dispatchTargetFilter, setDispatchTargetFilter] = useState('all'); // Level-2 ("show all") renders in pages of 200 rows so a huge session // history cannot mount thousands of un-virtualized rows at once. const [level2DisplayCount, setLevel2DisplayCount] = useState(SESSIONS_LEVEL_2_PAGE); @@ -667,39 +659,7 @@ const SessionsSection: React.FC = ({ }; }, [sessions]); - const dispatchTargetFilterOptions = useMemo(() => { - const options = new Map(); - for (const session of allTopLevelSessions) { - const key = dispatchFilterKey(session); - if (key === 'local') { - options.set(key, t('nav.sessions.filterLocal')); - continue; - } - const target = session.config.dispatchTarget; - if (target?.kind === 'ssh' || target?.kind === 'device') { - options.set(key, target.displayName); - } - } - return Array.from(options.entries()).map(([value, label]) => ({ value, label })); - }, [allTopLevelSessions, t]); - - useEffect(() => { - if ( - dispatchTargetFilter !== 'all' - && !dispatchTargetFilterOptions.some(option => option.value === dispatchTargetFilter) - ) { - setDispatchTargetFilter('all'); - } - }, [dispatchTargetFilter, dispatchTargetFilterOptions]); - - const topLevelSessions = useMemo( - () => dispatchTargetFilter === 'all' - ? allTopLevelSessions - : allTopLevelSessions.filter( - session => dispatchFilterKey(session) === dispatchTargetFilter, - ), - [allTopLevelSessions, dispatchTargetFilter], - ); + const topLevelSessions = allTopLevelSessions; const sessionDisplayLimit = useMemo(() => { const total = topLevelSessions.length; @@ -709,17 +669,14 @@ const SessionsSection: React.FC = ({ return SESSIONS_LEVEL_0; }, [topLevelSessions.length, expandLevel, level2DisplayCount]); - const totalTopLevelSessionCount = dispatchTargetFilter === 'all' - ? getEffectiveTopLevelSessionCount( - metadataPageState.totalTopLevelCount, - metadataPageState.syncedTopLevelCount, - allTopLevelSessions.length, - metadataPageState.isLoading, - ) - : topLevelSessions.length; + const totalTopLevelSessionCount = getEffectiveTopLevelSessionCount( + metadataPageState.totalTopLevelCount, + metadataPageState.syncedTopLevelCount, + allTopLevelSessions.length, + metadataPageState.isLoading, + ); const hasMoreUnloadedSessions = - dispatchTargetFilter === 'all' - && allTopLevelSessions.length < totalTopLevelSessionCount; + allTopLevelSessions.length < totalTopLevelSessionCount; const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); useEffect(() => { @@ -1222,28 +1179,6 @@ const SessionsSection: React.FC = ({ return (
- {dispatchTargetFilterOptions.length > 1 ? ( - - ) : null} - {topLevelSessions.length === 0 ? ( -
- {t('nav.sessions.noSessionsForTarget')} -
- ) : null} {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const relationship = resolveSessionRelationship(session); diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 1ad093d48e..d185b4070e 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -52,3 +52,9 @@ dispatch. into the target's `app.json`, preserves every other target setting, aborts rather than overwrite an unreadable or unparseable target config, and writes owner-only via a temp-file rename. +19. Dispatch target and status are session-scoped navigation metadata. Workspace + navigation must not install a dispatch target or filter its session list by + dispatch target. +20. The controller projects the initial user turn before waiting for target + startup. The target's `DialogTurnStarted` event adopts that pending turn in + place so queued work is visible without duplicating the message. diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 41f8b25f21..1d27bb3830 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -71,3 +71,19 @@ describe('dispatch preflight contract', () => { expect(isDispatchWorkspaceReady('~/repo', probe, '~/repo')).toBe(true); }); }); + +describe('dispatch navigation scope contract', () => { + const sessionsSection = read( + '../../app/components/NavPanel/sections/sessions/SessionsSection.tsx', + ); + const sessionsSectionStyles = read( + '../../app/components/NavPanel/sections/sessions/SessionsSection.scss', + ); + + it('keeps dispatch presentation on sessions without a workspace-level target filter', () => { + expect(sessionsSection).toContain('session.config.dispatchTarget'); + expect(sessionsSection).toContain('session.config.dispatchJobState'); + expect(sessionsSection).not.toContain('dispatchTargetFilter'); + expect(sessionsSectionStyles).not.toContain('session-target-filter'); + }); +}); diff --git a/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts b/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts new file mode 100644 index 0000000000..2f77c84f38 --- /dev/null +++ b/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts @@ -0,0 +1,29 @@ +import type { DialogTurn } from '@/flow_chat/types/flow-chat'; + +const OPTIMISTIC_DISPATCH_JOB_ID_KEY = '__bitfunOptimisticDispatchJobId'; + +export function markOptimisticDispatchTurnMetadata( + metadata: Record | undefined, + jobId: string, +): Record { + return { + ...metadata, + [OPTIMISTIC_DISPATCH_JOB_ID_KEY]: jobId, + }; +} + +export function optimisticDispatchTurnJobId(turn: DialogTurn): string | undefined { + const value = turn.userMessage.metadata?.[OPTIMISTIC_DISPATCH_JOB_ID_KEY]; + return typeof value === 'string' && value ? value : undefined; +} + +export function stripOptimisticDispatchTurnMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) { + return undefined; + } + const next = { ...metadata }; + delete next[OPTIMISTIC_DISPATCH_JOB_ID_KEY]; + return Object.keys(next).length > 0 ? next : undefined; +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 79a0255c00..88ccee49a3 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -13,6 +13,7 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import { notificationService } from '../../../shared/notification-system/services/NotificationService'; import type { DialogTurn, FlowToolItem, FlowUserSteeringItem, ModelRound, Session } from '../../types/flow-chat'; import type { FlowChatContext } from './types'; +import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; vi.mock('../../../shared/notification-system/services/NotificationService', () => ({ notificationService: { @@ -50,6 +51,90 @@ describe('resolveDialogTurnDisplayContent', () => { }); }); +describe('dispatch optimistic turn reconciliation', () => { + beforeEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + afterEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('lets the target DialogTurnStarted event adopt the visible pending turn', () => { + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([[ + 'dispatch-session', + { + sessionId: 'dispatch-session', + title: 'Remote task', + dialogTurns: [{ + id: 'dispatch_pending_job-1', + sessionId: 'dispatch-session', + agentType: 'agentic', + userMessage: { + id: 'user-dispatch-1', + content: 'Original visible prompt', + timestamp: 1000, + metadata: markOptimisticDispatchTurnMetadata( + { source: 'composer' }, + 'job-1', + ), + }, + modelRounds: [], + status: 'pending', + startTime: 1000, + }], + status: 'idle', + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + dispatchJobId: 'job-1', + }, + createdAt: 1000, + lastActiveAt: 1000, + error: null, + sessionKind: 'normal', + } as Session, + ]]), + activeSessionId: 'dispatch-session', + })); + + __test_only__.handleDialogTurnStarted(createFlowChatContext(), { + sessionId: 'dispatch-session', + turnId: 'target-turn-1', + turnIndex: 0, + userInput: 'Expanded target prompt', + userMessageMetadata: { targetFact: true }, + }); + + const turns = FlowChatStore.getInstance() + .getState() + .sessions.get('dispatch-session') + ?.dialogTurns; + expect(turns).toHaveLength(1); + expect(turns?.[0]).toMatchObject({ + id: 'target-turn-1', + userMessage: { + content: 'Original visible prompt', + metadata: { + source: 'composer', + targetFact: true, + }, + }, + backendTurnIndex: 0, + status: 'pending', + }); + expect(turns?.[0]?.userMessage.metadata) + .not.toHaveProperty('__bitfunOptimisticDispatchJobId'); + }); +}); + describe('mergeParamsPartialEventData', () => { it('appends Write argument deltas within a batch', () => { const merged = __test_only__.mergeParamsPartialEventData( diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 5cdbf0a4a8..024edc331e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -78,6 +78,11 @@ import { } from './RuntimeStatusModule'; import { requestPeerSessionRefresh } from './PeerSessionRefreshModule'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; +import { + optimisticDispatchTurnJobId, + stripOptimisticDispatchTurnMetadata, +} from '@/features/dispatch/optimisticDispatchTurn'; +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; const log = createLogger('EventHandlerModule'); const TURN_COMPLETION_QUIET_WINDOW_MS = 500; @@ -152,6 +157,7 @@ export const __test_only__ = { resolveDialogTurnDisplayContent, mergeParamsPartialEventData, findSubagentParentInfoByRound, + handleDialogTurnStarted, handleDialogTurnFailed, handleSubagentSessionLinked, }; @@ -1562,7 +1568,49 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { userMessageMetadata?.kind === 'manual_compaction' ? 'manual_compaction' : 'user_dialog'; const freshSession = store.getState().sessions.get(sessionId); - const dialogTurn = freshSession?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); + let dialogTurn = freshSession?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); + let projectedNewTurn = false; + + if ( + !dialogTurn + && freshSession + && isNonLocalDispatchTarget(freshSession.config.dispatchTarget) + && freshSession.config.dispatchJobId + ) { + const optimisticTurn = freshSession.dialogTurns.find( + turn => optimisticDispatchTurnJobId(turn) === freshSession.config.dispatchJobId, + ); + if (optimisticTurn) { + store.updateDialogTurn(sessionId, optimisticTurn.id, turn => { + const optimisticMetadata = stripOptimisticDispatchTurnMetadata( + turn.userMessage.metadata, + ); + const mergedMetadata = + optimisticMetadata || userMessageMetadata + ? { ...optimisticMetadata, ...userMessageMetadata } + : undefined; + return { + ...turn, + id: turnId, + kind: turn.kind || turnKind, + userMessage: { + ...turn.userMessage, + content: turn.userMessage.content || displayContent, + hasImages, + metadata: mergedMetadata, + images, + }, + status: 'pending', + backendTurnIndex: typeof turnIndex === 'number' ? turnIndex : undefined, + }; + }); + dialogTurn = store.getState().sessions + .get(sessionId) + ?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); + projectedNewTurn = !!dialogTurn; + } + } + if (!dialogTurn) { const newTurn: DialogTurn = { id: turnId, @@ -1582,6 +1630,10 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { backendTurnIndex: typeof turnIndex === 'number' ? turnIndex : undefined, }; store.addDialogTurn(sessionId, newTurn); + projectedNewTurn = true; + } + + if (projectedNewTurn) { reconcileBackgroundSubagentSession(sessionId); context.contentBuffers.set(sessionId, new Map()); @@ -1598,6 +1650,10 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { return; } + if (!dialogTurn) { + return; + } + if (typeof turnIndex === 'number' && dialogTurn.backendTurnIndex === undefined) { store.updateDialogTurn(sessionId, turnId, turn => ({ ...turn, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 1e0a3067aa..1bdf2829a8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -410,13 +410,13 @@ describe('MessageModule detached dispatch', () => { }); }); - function createDispatchContext(approvalPolicy: 'auto' | 'reject-and-report') { + function createDispatchContext(approvalPolicy: 'auto' | 'reject-and-report' | 'remote') { const session = { sessionId: 'dispatch-session', title: 'New Chat', titleStatus: 'generated', mode: 'agentic', - dialogTurns: [], + dialogTurns: [] as any[], config: { modelName: 'controller-model', dispatchTargetRequest: { @@ -444,6 +444,14 @@ describe('MessageModule detached dispatch', () => { activeSessionId: session.sessionId, sessions: new Map([[session.sessionId, session]]), }), + addDialogTurn: vi.fn((_sessionId: string, turn: any) => { + if (!session.dialogTurns.some(existing => existing.id === turn.id)) { + session.dialogTurns.push(turn); + } + }), + deleteDialogTurn: vi.fn((_sessionId: string, turnId: string) => { + session.dialogTurns = session.dialogTurns.filter(turn => turn.id !== turnId); + }), applyDispatchSnapshot: vi.fn(() => ({ applied: true, cursor: 0 })), updateSessionLastSubmittedMode: vi.fn(), updateSessionMode: vi.fn(), @@ -453,10 +461,46 @@ describe('MessageModule detached dispatch', () => { }; } - it('submits with existing workspace delivery and lets the target derive model/title', async () => { - const { context } = createDispatchContext('reject-and-report'); + it('projects the user message immediately while the target is still queued', async () => { + const { context, session } = createDispatchContext('reject-and-report'); + let resolveSubmit!: (value: { + accepted: boolean; + jobId: string; + sessionId: string; + state: string; + }) => void; + mockDispatchSubmit.mockImplementationOnce(() => new Promise(resolve => { + resolveSubmit = resolve; + })); + + const submission = sendMessage( + context, + 'expanded remote prompt', + 'dispatch-session', + 'run remote checks', + ); - await sendMessage(context, 'run remote checks', 'dispatch-session'); + expect(session.dialogTurns).toHaveLength(1); + expect(session.dialogTurns[0]).toMatchObject({ + id: 'dispatch_pending_job-1', + sessionId: 'dispatch-session', + agentType: 'agentic', + userMessage: { + content: 'run remote checks', + metadata: { + __bitfunOptimisticDispatchJobId: 'job-1', + }, + }, + modelRounds: [], + status: 'pending', + }); + resolveSubmit({ + accepted: true, + jobId: 'job-1', + sessionId: 'dispatch-session', + state: 'queued', + }); + await submission; expect(mockDispatchSubmit).toHaveBeenCalledWith({ target: { @@ -468,7 +512,7 @@ describe('MessageModule detached dispatch', () => { jobId: 'job-1', sessionId: 'dispatch-session', agentType: 'agentic', - prompt: 'run remote checks', + prompt: 'expanded remote prompt', approvalPolicy: 'reject-and-report', model: undefined, }); @@ -476,6 +520,17 @@ describe('MessageModule detached dispatch', () => { expect(mockBindSession).not.toHaveBeenCalled(); }); + it('removes the optimistic message when dispatch submission fails', async () => { + const { context, session } = createDispatchContext('reject-and-report'); + mockDispatchSubmit.mockRejectedValueOnce(new Error('target unavailable')); + + await expect( + sendMessage(context, 'run remote checks', 'dispatch-session'), + ).rejects.toThrow('target unavailable'); + + expect(session.dialogTurns).toEqual([]); + }); + it('requires a one-shot auto-approval confirmation before the actual submit', async () => { const { context } = createDispatchContext('auto'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 5dcc574b20..3ac3d443e1 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -29,6 +29,7 @@ import { dispatchApi } from '@/features/dispatch/dispatchApi'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; import { i18nService } from '@/infrastructure/i18n'; @@ -378,6 +379,38 @@ export async function sendMessage( handleTitleGeneration(context, sessionId, message); } + const optimisticTurnId = `dispatch_pending_${jobId}`; + const optimisticTurn: DialogTurn = { + id: optimisticTurnId, + sessionId, + agentType: currentAgentType, + userMessage: { + id: `user_dispatch_${Date.now()}`, + content: displayMessage || message, + timestamp: Date.now(), + metadata: markOptimisticDispatchTurnMetadata( + options?.userMessageMetadata, + jobId, + ), + }, + modelRounds: [], + status: 'pending', + startTime: Date.now(), + }; + context.flowChatStore.addDialogTurn(sessionId, optimisticTurn); + createdLocalTurnId = optimisticTurnId; + globalEventBus.emit( + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + { + sessionId, + turnId: optimisticTurnId, + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + } satisfies FlowChatPinTurnToTopRequest, + 'MessageModule', + ); + const response = await dispatchApi.submit({ target: targetRequest, workspaceDelivery: diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 6fa75f58aa..e8739d3690 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -161,10 +161,6 @@ "dispatchUnreachable": "Target unreachable", "dispatchUnreachableDetails": "Target unreachable: {{target}} · {{error}}", "dispatchTransportErrorFallback": "Transport request failed", - "filterLabel": "Runs on", - "filterAll": "All targets", - "filterLocal": "This computer", - "noSessionsForTarget": "No sessions on this target", "dispatchStates": { "submitting": "submitting", "submission_unknown": "checking submission", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 03b375532d..2be228e4c7 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -161,10 +161,6 @@ "dispatchUnreachable": "目标不可达", "dispatchUnreachableDetails": "目标不可达:{{target}} · {{error}}", "dispatchTransportErrorFallback": "传输请求失败", - "filterLabel": "运行位置", - "filterAll": "所有目标", - "filterLocal": "本机", - "noSessionsForTarget": "此目标上没有会话", "dispatchStates": { "submitting": "待提交", "submission_unknown": "正在确认提交状态", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 2ffef4ac9b..1aa2e0d149 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -161,10 +161,6 @@ "dispatchUnreachable": "目標無法連線", "dispatchUnreachableDetails": "目標無法連線:{{target}} · {{error}}", "dispatchTransportErrorFallback": "傳輸請求失敗", - "filterLabel": "執行位置", - "filterAll": "所有目標", - "filterLocal": "本機", - "noSessionsForTarget": "此目標上沒有工作階段", "dispatchStates": { "submitting": "待提交", "submission_unknown": "正在確認提交狀態", From e4282c5a3643626bb395433f827c17e0c42b1508 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 21:12:29 -0700 Subject: [PATCH 02/11] fix(dispatch): deliver source snapshots and reuse session controls --- docs/architecture/detached-task-dispatch.md | 16 +- .../core/src/service/dispatch/controller.rs | 41 +++- .../src/service/dispatch/device_controller.rs | 31 ++- .../assembly/core/src/service/dispatch/mod.rs | 56 +++--- .../core/src/service/dispatch/target.rs | 21 +- .../services-core/src/dispatch_workspace.rs | 184 ++++++++++++++---- .../dispatch/DispatchInstallDialog.test.tsx | 69 +++++++ .../dispatch/DispatchInstallDialog.tsx | 73 ++++++- .../features/dispatch/DispatchJobObserver.ts | 6 + src/web-ui/src/features/dispatch/README.md | 7 +- .../src/features/dispatch/dispatchJobStore.ts | 43 ++++ src/web-ui/src/features/dispatch/types.ts | 6 + .../src/flow_chat/components/ChatInput.tsx | 113 +++++++++-- .../ChatInputWorkspaceStrip.test.tsx | 37 ++++ .../components/ChatInputWorkspaceStrip.tsx | 32 ++- .../flow_chat/components/ModelSelector.tsx | 134 +++++++++++++ .../components/ModelSelectorExternal.test.tsx | 116 +++++++++++ .../flow-chat-manager/SessionModule.ts | 2 + .../src/flow_chat/store/FlowChatStore.ts | 58 ++++++ src/web-ui/src/flow_chat/types/flow-chat.ts | 4 + src/web-ui/src/locales/en-US/common.json | 3 + src/web-ui/src/locales/en-US/flow-chat.json | 4 + src/web-ui/src/locales/zh-CN/common.json | 3 + src/web-ui/src/locales/zh-CN/flow-chat.json | 4 + src/web-ui/src/locales/zh-TW/common.json | 3 + src/web-ui/src/locales/zh-TW/flow-chat.json | 4 + 26 files changed, 965 insertions(+), 105 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index 75b698b4ae..aa72e03185 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -36,7 +36,7 @@ storage is not used for workspace contents. `workspacePath` in a submit request identifies a directory on the target. It does not imply that similarly named directories on two machines are related. -Dispatch therefore supports two explicit delivery modes. +Dispatch therefore supports three explicit delivery modes. ### Existing target directory @@ -44,6 +44,20 @@ Dispatch therefore supports two explicit delivery modes. canonical path and Git facts before submit. BitFun never clones, fetches, checks out, stashes, or rewrites that directory as part of dispatch. +### One-shot source snapshot + +`snapshot-source` captures the controller workspace while honoring repository +ignore rules. It includes tracked and non-ignored source files, including +hidden source such as `.github/`, while excluding ignored dependency caches, +build output, and local secrets. It uses the same verified, one-shot upload, +materialization, result, and conflict rules as an exact snapshot. The filtered +input set is carried in the existing exact-snapshot wire envelope, so compatible +targets do not need a second materialization protocol. + +This is the default snapshot choice for ordinary source workspaces. Users who +need ignored runtime inputs must choose the exact mode explicitly and confirm +its wider data boundary. + ### One-shot exact snapshot `snapshot-exact` captures the controller workspace at submit time and materializes it diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 768478f686..ee3038324c 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -13,7 +13,7 @@ use serde_json::{json, Value}; use super::{ adopt_target_jobs, DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest, - OutboundDispatchRecord, OutboundDispatchStore, + DispatchWorkspaceSnapshotCaptureMode, OutboundDispatchRecord, OutboundDispatchStore, }; pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 2; @@ -408,6 +408,39 @@ async fn resolve_ssh_workspace( } Ok(workspace_path.to_string()) } + DispatchWorkspaceDeliveryRequest::SnapshotSource { + source_workspace_path, + } => { + let prepared = store + .prepare_workspace_snapshot( + job_id, + source_workspace_path, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await?; + let begin_request = json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": job_id, + "metadata": prepared.metadata, + }); + let committed = dispatch_ssh::upload_workspace_snapshot( + manager, + connection_id, + &begin_request, + &prepared.archive_path, + ) + .await?; + committed + .get("workspacePath") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + anyhow::anyhow!( + "dispatch target did not return the materialized workspace path" + ) + }) + } DispatchWorkspaceDeliveryRequest::SnapshotExact { source_workspace_path, sensitive_files_confirmed, @@ -418,7 +451,11 @@ async fn resolve_ssh_workspace( ); } let prepared = store - .prepare_workspace_snapshot(job_id, source_workspace_path) + .prepare_workspace_snapshot( + job_id, + source_workspace_path, + DispatchWorkspaceSnapshotCaptureMode::Exact, + ) .await?; let begin_request = json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index c0f3a9c04e..ff9999f661 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -19,7 +19,7 @@ use super::controller::{ }; use super::{ adopt_target_jobs, DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest, - OutboundDispatchRecord, OutboundDispatchStore, + DispatchWorkspaceSnapshotCaptureMode, OutboundDispatchRecord, OutboundDispatchStore, }; const DEVICE_WORKSPACE_CHUNK_BYTES: usize = 256 * 1024; @@ -339,6 +339,25 @@ async fn resolve_device_workspace( } Ok(path.to_string()) } + DispatchWorkspaceDeliveryRequest::SnapshotSource { + source_workspace_path, + } => { + let prepared = store + .prepare_workspace_snapshot( + job_id, + source_workspace_path, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await?; + upload_device_workspace( + rpc, + device_id, + job_id, + &prepared.archive_path, + &prepared.metadata, + ) + .await + } DispatchWorkspaceDeliveryRequest::SnapshotExact { source_workspace_path, sensitive_files_confirmed, @@ -349,7 +368,11 @@ async fn resolve_device_workspace( ); } let prepared = store - .prepare_workspace_snapshot(job_id, source_workspace_path) + .prepare_workspace_snapshot( + job_id, + source_workspace_path, + DispatchWorkspaceSnapshotCaptureMode::Exact, + ) .await?; upload_device_workspace( rpc, @@ -741,7 +764,9 @@ mod tests { .await .expect_err("a digest mismatch must fail the pull"); assert!( - error.to_string().contains("does not match the reported digest"), + error + .to_string() + .contains("does not match the reported digest"), "{error}" ); assert!( diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index d6e99b5d21..bf0b2e929f 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -8,7 +8,8 @@ use std::path::{Path, PathBuf}; use anyhow::Context as _; use bitfun_services_core::dispatch_workspace::{ - create_exact_workspace_snapshot, sha256_file, WorkspaceSnapshotMetadata, + create_exact_workspace_snapshot, create_source_workspace_snapshot, sha256_file, + WorkspaceSnapshotMetadata, }; use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; use chrono::{DateTime, Utc}; @@ -26,29 +27,25 @@ pub use bitfun_services_core::dispatch_workspace::{ }; #[cfg(feature = "ssh-remote")] pub use controller::{ - answer as answer_dispatch, append as append_dispatch, cancel as cancel_dispatch, - install_cli_cancel as cancel_dispatch_cli_install, + answer as answer_dispatch, append as append_dispatch, apply_result as apply_dispatch_result, + cancel as cancel_dispatch, install_cli_cancel as cancel_dispatch_cli_install, install_cli_poll as poll_dispatch_cli_install, install_cli_source_start as start_dispatch_cli_source_build, - install_cli_start as start_dispatch_cli_install, - list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, - apply_result as apply_dispatch_result, probe_target as probe_dispatch_target, - pull_result as pull_dispatch_result, - status as get_dispatch_status, - submit as submit_dispatch, sync_model_config as sync_dispatch_model_config, - DispatchAnswerRequest, DispatchApplyResultRequest, DispatchAppendRequest, - DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, - DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, - DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchTargetOption, + install_cli_start as start_dispatch_cli_install, list_jobs as list_dispatch_jobs, + list_targets as list_dispatch_targets, probe_target as probe_dispatch_target, + pull_result as pull_dispatch_result, status as get_dispatch_status, submit as submit_dispatch, + sync_model_config as sync_dispatch_model_config, DispatchAnswerRequest, DispatchAppendRequest, + DispatchApplyResultRequest, DispatchConnectionRequest, DispatchInstallPollRequest, + DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchListTargetsRequest, DispatchPermissionReplyKind, DispatchProbeTargetRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchTargetOption, }; #[cfg(feature = "ssh-remote")] pub use device_controller::{ answer_device as answer_device_dispatch, append_device as append_device_dispatch, cancel_device as cancel_device_dispatch, list_device_jobs as list_device_dispatch_jobs, probe_device as probe_device_dispatch_target, - pull_device_result as pull_device_dispatch_result, - status_device as get_device_dispatch_status, + pull_device_result as pull_device_dispatch_result, status_device as get_device_dispatch_status, submit_device as submit_device_dispatch, DeviceDispatchRpc, }; pub use target::{DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest}; @@ -69,9 +66,19 @@ pub struct PreparedOutboundWorkspaceSnapshot { #[serde(rename_all = "camelCase")] struct OutboundWorkspaceSnapshotRecord { source_workspace_path: String, + #[serde(default)] + capture_mode: DispatchWorkspaceSnapshotCaptureMode, metadata: WorkspaceSnapshotMetadata, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DispatchWorkspaceSnapshotCaptureMode { + Source, + #[default] + Exact, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct DispatchTargetJobEntry { @@ -355,6 +362,7 @@ impl OutboundDispatchStore { &self, job_id: &str, source_workspace_path: &str, + capture_mode: DispatchWorkspaceSnapshotCaptureMode, ) -> anyhow::Result { validate_id(job_id)?; let source = std::path::PathBuf::from(source_workspace_path.trim()); @@ -396,10 +404,8 @@ impl OutboundDispatchStore { .read_optional::(&record_path) .await? { - if record.source_workspace_path != source_wire { - anyhow::bail!( - "dispatch jobId is already bound to a snapshot from another source workspace" - ); + if record.source_workspace_path != source_wire || record.capture_mode != capture_mode { + anyhow::bail!("dispatch jobId is already bound to another workspace snapshot"); } let archive = archive_path.clone(); let expected = record.metadata.clone(); @@ -433,13 +439,19 @@ impl OutboundDispatchStore { let package_source = source.clone(); let package_archive = archive_path.clone(); - let metadata = tokio::task::spawn_blocking(move || { - create_exact_workspace_snapshot(&package_source, &package_archive) + let metadata = tokio::task::spawn_blocking(move || match capture_mode { + DispatchWorkspaceSnapshotCaptureMode::Source => { + create_source_workspace_snapshot(&package_source, &package_archive) + } + DispatchWorkspaceSnapshotCaptureMode::Exact => { + create_exact_workspace_snapshot(&package_source, &package_archive) + } }) .await .map_err(|error| anyhow::anyhow!("snapshot packaging task failed: {error}"))??; let record = OutboundWorkspaceSnapshotRecord { source_workspace_path: source_wire, + capture_mode, metadata: metadata.clone(), }; self.json_store diff --git a/src/crates/assembly/core/src/service/dispatch/target.rs b/src/crates/assembly/core/src/service/dispatch/target.rs index 1d0c18618d..a1cc321326 100644 --- a/src/crates/assembly/core/src/service/dispatch/target.rs +++ b/src/crates/assembly/core/src/service/dispatch/target.rs @@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize}; pub enum DispatchWorkspaceDeliveryRequest { #[default] Existing, + SnapshotSource { + #[serde(rename = "sourceWorkspacePath")] + source_workspace_path: String, + }, SnapshotExact { #[serde(rename = "sourceWorkspacePath")] source_workspace_path: String, @@ -14,7 +18,6 @@ pub enum DispatchWorkspaceDeliveryRequest { }, } - /// The execution location selected while a chat session is being created. /// /// Dispatch is deliberately orthogonal to `SessionExecutionTarget`: the latter @@ -40,7 +43,6 @@ pub enum DispatchTargetRequest { }, } - impl DispatchTargetRequest { pub fn is_local(&self) -> bool { matches!(self, Self::Local) @@ -135,4 +137,19 @@ mod tests { }) ); } + + #[test] + fn source_snapshot_requires_only_an_explicit_source() { + let value = serde_json::to_value(DispatchWorkspaceDeliveryRequest::SnapshotSource { + source_workspace_path: "/work/app".to_string(), + }) + .expect("serialize delivery"); + assert_eq!( + value, + serde_json::json!({ + "kind": "snapshot-source", + "sourceWorkspacePath": "/work/app" + }) + ); + } } diff --git a/src/crates/services/services-core/src/dispatch_workspace.rs b/src/crates/services/services-core/src/dispatch_workspace.rs index 50a5125290..e7611bd6a4 100644 --- a/src/crates/services/services-core/src/dispatch_workspace.rs +++ b/src/crates/services/services-core/src/dispatch_workspace.rs @@ -71,6 +71,27 @@ pub struct WorkspaceSnapshotMetadata { pub uncompressed_bytes: u64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkspaceSnapshotCaptureMode { + Source, + Exact, +} + +impl WorkspaceSnapshotCaptureMode { + fn manifest_mode(self) -> &'static str { + // The transport envelope remains the existing exact-snapshot contract: + // source filtering happens while the controller captures the input set, + // then that complete captured set is signed and transferred exactly. + "exact" + } + + fn includes_ignored_files(self) -> bool { + // True relative to the captured input set. Source mode has already + // removed ignored paths before the manifest is constructed. + true + } +} + /// What the target changed, relative to the snapshot it was given. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -220,7 +241,12 @@ pub fn create_workspace_result_bundle( ..summary.clone() }) .context("encode dispatch result summary")?; - append_bytes(&mut archive, RESULT_SUMMARY_ARCHIVE_PATH, &summary_bytes, false)?; + append_bytes( + &mut archive, + RESULT_SUMMARY_ARCHIVE_PATH, + &summary_bytes, + false, + )?; archive .into_inner() @@ -357,8 +383,7 @@ pub fn apply_workspace_result_bundle( } let destination = resolve_workspace_child(&workspace, &relative_wire)?; if let Some(parent) = destination.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let mut bytes = Vec::new(); entry @@ -409,16 +434,38 @@ pub fn create_exact_workspace_snapshot( source: &Path, archive_path: &Path, ) -> Result { - let result = create_exact_workspace_snapshot_inner(source, archive_path); + create_workspace_snapshot(source, archive_path, WorkspaceSnapshotCaptureMode::Exact) +} + +/// Package workspace source while honoring repository ignore rules. +/// +/// Hidden source files remain eligible (for example `.github/workflows`), but +/// ignored dependency caches and build output are not transferred. Callers +/// that need byte-for-byte workspace contents must use the explicit exact +/// snapshot path instead. +pub fn create_source_workspace_snapshot( + source: &Path, + archive_path: &Path, +) -> Result { + create_workspace_snapshot(source, archive_path, WorkspaceSnapshotCaptureMode::Source) +} + +fn create_workspace_snapshot( + source: &Path, + archive_path: &Path, + capture_mode: WorkspaceSnapshotCaptureMode, +) -> Result { + let result = create_workspace_snapshot_inner(source, archive_path, capture_mode); if result.is_err() { let _ = fs::remove_file(archive_path); } result } -fn create_exact_workspace_snapshot_inner( +fn create_workspace_snapshot_inner( source: &Path, archive_path: &Path, + capture_mode: WorkspaceSnapshotCaptureMode, ) -> Result { let source_metadata = fs::symlink_metadata(source) .with_context(|| format!("inspect workspace {}", source.display()))?; @@ -448,14 +495,25 @@ fn create_exact_workspace_snapshot_inner( archive.mode(tar::HeaderMode::Deterministic); let mut walk = WalkBuilder::new(&source); - walk.hidden(false) - .ignore(false) - .git_ignore(false) - .git_global(false) - .git_exclude(false) - .parents(false) - .follow_links(false) - .sort_by_file_path(|left, right| left.cmp(right)); + walk.hidden(false).follow_links(false); + match capture_mode { + WorkspaceSnapshotCaptureMode::Source => { + walk.ignore(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .require_git(false) + .parents(false); + } + WorkspaceSnapshotCaptureMode::Exact => { + walk.ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false); + } + } + walk.sort_by_file_path(|left, right| left.cmp(right)); let filter_root = source.clone(); walk.filter_entry(move |entry| { entry.path() == filter_root || entry.file_name().to_str() != Some(".git") @@ -542,8 +600,8 @@ fn create_exact_workspace_snapshot_inner( let manifest = WorkspaceSnapshotManifest { format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - mode: "exact".to_string(), - includes_ignored_files: true, + mode: capture_mode.manifest_mode().to_string(), + includes_ignored_files: capture_mode.includes_ignored_files(), excludes_git_metadata: true, file_count, directory_count, @@ -828,9 +886,13 @@ fn validate_manifest( manifest: &WorkspaceSnapshotManifest, expected: &WorkspaceSnapshotMetadata, ) -> Result<()> { + let compatible_capture_mode = match manifest.mode.as_str() { + "exact" => manifest.includes_ignored_files, + "source" => !manifest.includes_ignored_files, + _ => false, + }; if manifest.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION - || manifest.mode != "exact" - || !manifest.includes_ignored_files + || !compatible_capture_mode || !manifest.excludes_git_metadata { bail!("workspace snapshot manifest contract is incompatible"); @@ -1161,6 +1223,39 @@ mod tests { assert!(manifest.excludes_git_metadata); } + #[test] + fn source_snapshot_keeps_hidden_source_and_excludes_ignored_build_output() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(source.join(".git")).expect("repository marker"); + fs::create_dir_all(source.join(".github/workflows")).expect("hidden source directory"); + fs::create_dir_all(source.join("target/debug")).expect("ignored build directory"); + fs::write(source.join(".gitignore"), b"target/\n.env\n").expect("gitignore"); + fs::write(source.join(".github/workflows/check.yml"), b"name: check") + .expect("hidden source file"); + fs::write(source.join("source.rs"), b"fn main() {}").expect("source"); + fs::write(source.join(".env"), b"SECRET=test").expect("ignored secret"); + fs::write(source.join("target/debug/app"), b"build output").expect("build output"); + let archive = temp.path().join("source-snapshot.tar.gz"); + + let metadata = + create_source_workspace_snapshot(&source, &archive).expect("create source snapshot"); + let destination = temp.path().join("destination"); + let manifest = + extract_workspace_snapshot(&archive, &destination, &metadata).expect("extract"); + + assert_eq!(manifest.mode, "exact"); + assert!(manifest.includes_ignored_files); + assert_eq!( + fs::read(destination.join(".github/workflows/check.yml")).expect("workflow"), + b"name: check" + ); + assert!(destination.join("source.rs").is_file()); + assert!(destination.join(".gitignore").is_file()); + assert!(!destination.join(".env").exists()); + assert!(!destination.join("target").exists()); + } + #[test] fn result_bundle_reports_adds_edits_and_deletes_without_git() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1229,7 +1324,11 @@ mod tests { temp: &Path, seed: &[(&str, &[u8])], mutate: impl FnOnce(&Path), - ) -> (WorkspaceResultSummary, std::path::PathBuf, std::path::PathBuf) { + ) -> ( + WorkspaceResultSummary, + std::path::PathBuf, + std::path::PathBuf, + ) { let source = temp.join("source"); fs::create_dir_all(&source).expect("source"); for (name, bytes) in seed { @@ -1241,8 +1340,7 @@ mod tests { let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); mutate(&target); let bundle = temp.join("result.tar.gz"); - let summary = - create_workspace_result_bundle(&target, &baseline, &bundle).expect("bundle"); + let summary = create_workspace_result_bundle(&target, &baseline, &bundle).expect("bundle"); // A second extraction stands in for the controller's own copy of S0. let local = temp.join("local"); extract_workspace_snapshot(&archive, &local, &metadata).expect("extract local"); @@ -1254,7 +1352,11 @@ mod tests { let temp = tempfile::tempdir().expect("tempdir"); let (summary, bundle, local) = snapshot_and_diff( temp.path(), - &[("keep.txt", b"same"), ("edit.txt", b"before"), ("gone.txt", b"bye")], + &[ + ("keep.txt", b"same"), + ("edit.txt", b"before"), + ("gone.txt", b"bye"), + ], |target| { fs::write(target.join("edit.txt"), b"after").expect("edit"); fs::remove_file(target.join("gone.txt")).expect("delete"); @@ -1262,13 +1364,16 @@ mod tests { }, ); - let outcome = apply_workspace_result_bundle(&bundle, &local, &summary, false) - .expect("apply"); + let outcome = + apply_workspace_result_bundle(&bundle, &local, &summary, false).expect("apply"); assert!(!outcome.aborted, "an untouched local tree has no conflicts"); assert!(outcome.conflicts.is_empty()); assert_eq!(fs::read(local.join("edit.txt")).expect("edit"), b"after"); assert_eq!(fs::read(local.join("new.txt")).expect("new"), b"created"); - assert!(!local.join("gone.txt").exists(), "deletions must be applied"); + assert!( + !local.join("gone.txt").exists(), + "deletions must be applied" + ); assert_eq!( fs::read(local.join("keep.txt")).expect("keep"), b"same", @@ -1279,18 +1384,15 @@ mod tests { #[test] fn a_locally_edited_file_blocks_the_apply_instead_of_being_overwritten() { let temp = tempfile::tempdir().expect("tempdir"); - let (summary, bundle, local) = snapshot_and_diff( - temp.path(), - &[("shared.txt", b"before")], - |target| { + let (summary, bundle, local) = + snapshot_and_diff(temp.path(), &[("shared.txt", b"before")], |target| { fs::write(target.join("shared.txt"), b"target edit").expect("edit"); - }, - ); + }); // The user kept working locally while the job ran. fs::write(local.join("shared.txt"), b"my local work").expect("local edit"); - let outcome = apply_workspace_result_bundle(&bundle, &local, &summary, false) - .expect("apply"); + let outcome = + apply_workspace_result_bundle(&bundle, &local, &summary, false).expect("apply"); assert!(outcome.aborted, "a conflict must stop the apply"); assert_eq!( outcome.conflicts, @@ -1310,25 +1412,27 @@ mod tests { let forced = apply_workspace_result_bundle(&bundle, &local, &summary, true).expect("forced apply"); assert!(!forced.aborted); - assert_eq!(fs::read(local.join("shared.txt")).expect("local"), b"target edit"); + assert_eq!( + fs::read(local.join("shared.txt")).expect("local"), + b"target edit" + ); } #[test] fn a_tampered_bundle_is_rejected_before_anything_is_written() { let temp = tempfile::tempdir().expect("tempdir"); - let (summary, bundle, local) = snapshot_and_diff( - temp.path(), - &[("a.txt", b"before")], - |target| { + let (summary, bundle, local) = + snapshot_and_diff(temp.path(), &[("a.txt", b"before")], |target| { fs::write(target.join("a.txt"), b"after").expect("edit"); - }, - ); + }); fs::write(&bundle, b"not the bundle you verified").expect("tamper"); let error = apply_workspace_result_bundle(&bundle, &local, &summary, false) .expect_err("a tampered bundle must be refused"); assert!( - error.to_string().contains("does not match the reported digest"), + error + .to_string() + .contains("does not match the reported digest"), "{error}" ); assert_eq!(fs::read(local.join("a.txt")).expect("local"), b"before"); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 44fd2a4ffc..722850d96b 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -306,6 +306,75 @@ describe('DispatchInstallDialog installation lifecycle', () => { expect(container.textContent).toContain('dispatch.snapshotResultLocationHint'); }); + it('defaults an unbound target to a source snapshot and preserves target model facts', async () => { + const onReady = vi.fn(); + mocks.probeTarget.mockResolvedValue({ + cliInstalled: true, + os: 'linux', + arch: 'x86_64', + installSupported: false, + protocol: { + protocolVersion: 2, + cliVersion: '1.2.3', + os: 'linux', + arch: 'x86_64', + capabilities: [ + 'persistent_jobs', + 'cursor_events', + 'detached_worker', + 'frontend_event_projection', + 'workspace_serialization', + 'workspace_snapshot_exact', + 'workspace_snapshot_chunked', + 'approval_remote', + ], + modelConfigured: true, + availableModels: ['model-a', 'model-b'], + defaultModel: 'model-b', + }, + }); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const sourceSnapshot = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.deliverySourceSnapshot')); + expect(sourceSnapshot?.getAttribute('aria-checked')).toBe('true'); + + const remoteApproval = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.approvalRemote')); + await act(async () => { + remoteApproval?.click(); + }); + const useTarget = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.useTarget')); + expect(useTarget?.disabled).toBe(false); + + await act(async () => { + useTarget?.click(); + }); + expect(onReady).toHaveBeenCalledWith(expect.objectContaining({ + workspaceDelivery: { + kind: 'snapshot-source', + sourceWorkspacePath: '/home/me/project', + }, + approvalPolicy: 'remote', + availableModels: ['model-a', 'model-b'], + defaultModel: 'model-b', + })); + }); + it('cancels an acknowledged installer when the parent closes the dialog during polling', async () => { const poll = createDeferred<{ cursor: number; diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index bfeb330963..be289789d7 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -71,7 +71,9 @@ export const DispatchInstallDialog: React.FC = ({ const { t } = useI18n('common'); const [workspacePath, setWorkspacePath] = useState(''); const [approvalPolicy, setApprovalPolicy] = useState(null); - const [deliveryKind, setDeliveryKind] = useState<'existing' | 'snapshot-exact'>('existing'); + const [deliveryKind, setDeliveryKind] = useState< + 'existing' | 'snapshot-source' | 'snapshot-exact' + >('existing'); const [sensitiveFilesConfirmed, setSensitiveFilesConfirmed] = useState(false); const [probe, setProbe] = useState(null); const [probedWorkspaceInput, setProbedWorkspaceInput] = useState(null); @@ -126,9 +128,14 @@ export const DispatchInstallDialog: React.FC = ({ useEffect(() => { if (!open || !targetId) return; const initialPath = target?.defaultWorkspace?.trim() ?? ''; + const initialDelivery = initialPath + ? 'existing' + : sourceWorkspacePath?.trim() + ? 'snapshot-source' + : 'existing'; setWorkspacePath(initialPath); setApprovalPolicy(null); - setDeliveryKind('existing'); + setDeliveryKind(initialDelivery); setSensitiveFilesConfirmed(false); setProbe(null); setProbedWorkspaceInput(null); @@ -138,7 +145,7 @@ export const DispatchInstallDialog: React.FC = ({ setSyncingModel(false); setError(null); void runProbe(initialPath); - }, [open, runProbe, target?.defaultWorkspace, targetId]); + }, [open, runProbe, sourceWorkspacePath, target?.defaultWorkspace, targetId]); const clearActiveInstall = useCallback((generation: number) => { if (activeInstallRef.current?.generation === generation) { @@ -347,9 +354,11 @@ export const DispatchInstallDialog: React.FC = ({ const requiredCapabilities = [ ...BASE_DISPATCH_CAPABILITIES, ...(selectedApprovalCapability ? [selectedApprovalCapability] : []), - ...(deliveryKind === 'snapshot-exact' + ...(deliveryKind === 'snapshot-source' ? ['workspace_snapshot_exact', 'workspace_snapshot_chunked'] - : []), + : deliveryKind === 'snapshot-exact' + ? ['workspace_snapshot_exact', 'workspace_snapshot_chunked'] + : []), ]; const missingCapabilities = protocol ? requiredCapabilities.filter(capability => !protocol.capabilities.includes(capability)) @@ -362,9 +371,11 @@ export const DispatchInstallDialog: React.FC = ({ !!protocol && !probe.protocolError && protocolCompatible; - const workspaceReady = deliveryKind === 'snapshot-exact' - ? !!sourceWorkspacePath?.trim() && sensitiveFilesConfirmed - : isDispatchWorkspaceReady(workspacePath, workspace, probedWorkspaceInput ?? undefined); + const workspaceReady = deliveryKind === 'snapshot-source' + ? !!sourceWorkspacePath?.trim() + : deliveryKind === 'snapshot-exact' + ? !!sourceWorkspacePath?.trim() && sensitiveFilesConfirmed + : isDispatchWorkspaceReady(workspacePath, workspace, probedWorkspaceInput ?? undefined); const modelReady = protocol?.modelConfigured === true; const ready = cliReady && workspaceReady && modelReady && approvalPolicy !== null; @@ -380,7 +391,12 @@ export const DispatchInstallDialog: React.FC = ({ ? workspace?.path?.trim() || workspacePath.trim() : ''; const workspaceDelivery: DispatchWorkspaceDeliveryRequest = - deliveryKind === 'snapshot-exact' + deliveryKind === 'snapshot-source' + ? { + kind: 'snapshot-source', + sourceWorkspacePath: sourceWorkspacePath!.trim(), + } + : deliveryKind === 'snapshot-exact' ? { kind: 'snapshot-exact', sourceWorkspacePath: sourceWorkspacePath!.trim(), @@ -407,6 +423,8 @@ export const DispatchInstallDialog: React.FC = ({ }, workspaceDelivery, approvalPolicy, + availableModels: protocol?.availableModels, + defaultModel: protocol?.defaultModel, }); }; @@ -461,6 +479,25 @@ export const DispatchInstallDialog: React.FC = ({ {deliveryKind === 'existing' ? : null} +
- ) : ( + ) : deliveryKind === 'snapshot-exact' ? ( <>
{t('dispatch.snapshotSource')} @@ -536,6 +573,22 @@ export const DispatchInstallDialog: React.FC = ({
+ ) : ( + <> +
+ {t('dispatch.snapshotSource')} + {sourceWorkspacePath} + {t('dispatch.sourceSnapshotHint')} +
+
+ + {t('dispatch.snapshotResultLocation')} + + + {t('dispatch.snapshotResultLocationHint')} + +
+ )} diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index 76d7e7285e..fb535fac0b 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -171,6 +171,9 @@ function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): b target: job.target, jobId: job.jobId, approvalPolicy: job.approvalPolicy, + model: job.model, + availableModels: job.availableModels, + defaultModel: job.defaultModel, state: job.state, cursor: job.cursor, }); @@ -197,6 +200,9 @@ function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): b target: job.target, jobId: job.jobId, approvalPolicy: job.approvalPolicy, + model: job.model, + availableModels: job.availableModels, + defaultModel: job.defaultModel, state: job.state, cursor: 0, }); diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index d185b4070e..0b65f84fa1 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -35,9 +35,10 @@ dispatch. implemented, so creating an unmarked child projection would violate the observer-only persistence and cancellation boundary. 13. Workspace delivery is explicit. `existing` addresses a target directory; - `snapshot-exact` transfers one verified source snapshot, including ignored - and hidden regular files but excluding `.git`. It is never live or - bidirectional synchronization. + `snapshot-source` transfers tracked and non-ignored source without ignored + build output or secrets; `snapshot-exact` transfers one verified source + snapshot, including ignored and hidden regular files but excluding `.git`. + Neither snapshot mode is live or bidirectional synchronization. 14. Cursor pulls are multi-observer safe. Truncation and omitted events are visible completeness facts and must not be rendered as a full transcript. 15. The observer continues bounded polling while the window is hidden so diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index c87055090d..f49f525612 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -40,6 +40,8 @@ export interface DispatchObserverJob { approvalPolicy: DispatchApprovalPolicy; workspaceDelivery: DispatchWorkspaceDeliveryRequest; model?: string; + availableModels?: string[]; + defaultModel?: string; cursor: number; state: DispatchJobState; terminalDrained?: boolean; @@ -95,6 +97,8 @@ interface DispatchJobStoreState { ) => void; resetReplay: (jobId: string) => void; updateTitle: (jobId: string, title: string) => void; + updateModel: (jobId: string, model: string) => void; + updateApprovalPolicy: (jobId: string, policy: DispatchApprovalPolicy) => void; dismissJob: (jobId: string) => void; removeJob: (jobId: string) => void; clear: () => void; @@ -326,6 +330,45 @@ export const useDispatchJobStore = create()( }); }, + updateModel: (jobId, model) => { + set(state => { + const current = state.jobs[jobId]; + const normalizedModel = model.trim(); + if (!current || !normalizedModel || current.model === normalizedModel) { + return state; + } + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + model: normalizedModel, + updatedAt: Date.now(), + }, + }, + }; + }); + }, + + updateApprovalPolicy: (jobId, approvalPolicy) => { + set(state => { + const current = state.jobs[jobId]; + if (!current || current.approvalPolicy === approvalPolicy) { + return state; + } + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + approvalPolicy, + updatedAt: Date.now(), + }, + }, + }; + }); + }, + dismissJob: (jobId) => { set(state => { const jobs = { ...state.jobs }; diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index 086d868ebb..0bea7c94f8 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -21,6 +21,10 @@ export type DispatchTarget = export type DispatchApprovalPolicy = 'auto' | 'reject-and-report' | 'remote'; export type DispatchWorkspaceDeliveryRequest = | { kind: 'existing' } + | { + kind: 'snapshot-source'; + sourceWorkspacePath: string; + } | { kind: 'snapshot-exact'; sourceWorkspacePath: string; @@ -241,6 +245,8 @@ export interface DispatchSelection { workspaceDelivery: DispatchWorkspaceDeliveryRequest; approvalPolicy: DispatchApprovalPolicy; model?: string; + availableModels?: string[]; + defaultModel?: string; } export function isNonLocalDispatchTarget( diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 029e2433d9..1f6bf59a5b 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -133,6 +133,7 @@ import { import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; import { shouldConfirmDispatchAutoApproval } from '@/features/dispatch/dispatchPreflight'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; import { useComposerVoiceInput } from './voice/useComposerVoiceInput'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; @@ -1996,6 +1997,41 @@ export const ChatInput: React.FC = ({ } }, [isAcpTargetSession, permissionModeSaving, t, toolPermissionConfig]); + const dispatchPermissionMode: ChatInputPermissionMode = + effectiveTargetSession?.config.dispatchApprovalPolicy === 'auto' + ? 'auto' + : effectiveTargetSession?.config.dispatchApprovalPolicy === 'reject-and-report' + ? 'reject' + : 'ask'; + const dispatchSubmissionOptionsLocked = + effectiveTargetSession?.config.dispatchJobState !== 'submitting' + && effectiveTargetSession?.config.dispatchJobState !== 'submission_unknown'; + const handleDispatchPermissionModeChange = useCallback(( + nextMode: Exclude, + ) => { + if (!effectiveTargetSessionId || dispatchSubmissionOptionsLocked) { + return; + } + const approvalPolicy = + nextMode === 'auto' || nextMode === 'full_access' + ? 'auto' + : nextMode === 'reject' + ? 'reject-and-report' + : 'remote'; + FlowChatStore.getInstance().updateSessionDispatchApprovalPolicy( + effectiveTargetSessionId, + approvalPolicy, + ); + const jobId = effectiveTargetSession?.config.dispatchJobId; + if (jobId) { + dispatchJobStore.getState().updateApprovalPolicy(jobId, approvalPolicy); + } + }, [ + dispatchSubmissionOptionsLocked, + effectiveTargetSession?.config.dispatchJobId, + effectiveTargetSessionId, + ]); + /** * Checking worktree isolation only arms the empty session. The first prompt * materializes the worktree after it has visibly been submitted. @@ -2058,6 +2094,8 @@ export const ChatInput: React.FC = ({ // Undefined is intentional: the target's probed default model wins // unless a future preflight selector records an explicit choice. dispatchModel: selection.model, + dispatchAvailableModels: selection.availableModels, + dispatchDefaultModel: selection.defaultModel, }, effectiveSendAgentType, ); @@ -2085,7 +2123,10 @@ export const ChatInput: React.FC = ({ const jobId = effectiveTargetSession?.config.dispatchJobId; const jobState = effectiveTargetSession?.config.dispatchJobState; const completedSnapshotJobId = - effectiveTargetSession?.config.dispatchWorkspaceDelivery?.kind === 'snapshot-exact' && + ( + effectiveTargetSession?.config.dispatchWorkspaceDelivery?.kind === 'snapshot-source' + || effectiveTargetSession?.config.dispatchWorkspaceDelivery?.kind === 'snapshot-exact' + ) && (jobState === 'succeeded' || jobState === 'failed') && jobId ? jobId @@ -2116,6 +2157,35 @@ export const ChatInput: React.FC = ({ workspacePath, ]); + const dispatchModelSelection = useMemo(() => { + if (!usesDispatchTransport || !effectiveTargetSession) { + return undefined; + } + const target = effectiveTargetSession.config.dispatchTarget; + const providerLabel = + target && target.kind !== 'local' + ? target.displayName + : t('chatInput.dispatch.remoteTarget'); + const sessionId = effectiveTargetSession.sessionId; + const jobId = effectiveTargetSession.config.dispatchJobId; + const state = effectiveTargetSession.config.dispatchJobState; + return { + models: effectiveTargetSession.config.dispatchAvailableModels ?? [], + selectedModelId: effectiveTargetSession.config.dispatchModel, + defaultModelId: effectiveTargetSession.config.dispatchDefaultModel, + providerLabel, + disabled: + state !== 'submitting' + && state !== 'submission_unknown', + onSelect: (modelId: string) => { + FlowChatStore.getInstance().updateSessionDispatchModel(sessionId, modelId); + if (jobId) { + dispatchJobStore.getState().updateModel(jobId, modelId); + } + }, + }; + }, [effectiveTargetSession, t, usesDispatchTransport]); + const handleHidePermissionModeControl = useCallback(async () => { try { await configManager.setConfig('app.flow_chat.show_permission_mode_control', false); @@ -2823,7 +2893,8 @@ export const ChatInput: React.FC = ({ setSelectedNonExternalSlashCandidateId(undefined); } - const localSlashCommandsEnabled = !isAcpInputSession && !usesDispatchTransport; + const promptSlashCommandsEnabled = !isAcpInputSession; + const localSlashCommandsEnabled = promptSlashCommandsEnabled && !usesDispatchTransport; const trimmed = text.trim(); const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); const isCompactCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/compact'); @@ -2842,7 +2913,7 @@ export const ChatInput: React.FC = ({ const hasWhitespace = /\s/.test(afterSlash); const pickerQuery = getSlashCommandPickerQuery(text); const query = pickerQuery ?? afterSlash.trimStart().split(/\s+/, 1)[0]?.toLowerCase?.() ?? ''; - const matchedMcpPrompt = localSlashCommandsEnabled + const matchedMcpPrompt = promptSlashCommandsEnabled ? resolveTypedMcpPromptCommand(text) : null; @@ -3832,15 +3903,17 @@ export const ChatInput: React.FC = ({ : expandedMessage); const messageCharCount = getCharacterCount(message); // Voice transcripts are always message content; they must not accidentally execute local commands. - const localSlashCommandsEnabled = + const promptSlashCommandsEnabled = !isAcpInputSession && - !usesDispatchTransport && messageOverride === undefined; + const localSlashCommandsEnabled = + promptSlashCommandsEnabled && + !usesDispatchTransport; const parsedReload = messageOverride === undefined ? parseReloadCommand(message) : null; - if (localSlashCommandsEnabled && await submitExternalPromptCommandFromInput( + if (promptSlashCommandsEnabled && await submitExternalPromptCommandFromInput( message, originalMessage, originalPendingLargePastes, @@ -3888,7 +3961,7 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && resolveTypedMcpPromptCommand(message)) { + if (promptSlashCommandsEnabled && resolveTypedMcpPromptCommand(message)) { await submitMcpPromptFromInput(); return; } @@ -5636,7 +5709,7 @@ export const ChatInput: React.FC = ({
- {voiceInput.phase === 'idle' && !usesDispatchTransport ? ( + {voiceInput.phase === 'idle' ? (
= ({ maxTokens={tokenUsage.max} contextUsageSource={tokenUsage.source} onLoadingChange={handleModelLoadingChange} + externalSelection={dispatchModelSelection} />
) : null} @@ -5664,12 +5738,23 @@ export const ChatInput: React.FC = ({ dispatchControl={dispatchControl} worktreeControl={worktreeControl} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} - permissionControl={showPermissionModeControl && !usesDispatchTransport ? { - mode: permissionMode, - saving: permissionModeSaving, - onChange: isAcpTargetSession ? undefined : handlePermissionModeChange, - onHide: isAcpTargetSession ? undefined : handleHidePermissionModeControl, - } : undefined} + permissionControl={showPermissionModeControl + ? usesDispatchTransport + ? { + mode: dispatchPermissionMode, + disabled: dispatchSubmissionOptionsLocked, + options: ['ask', 'auto', 'reject'], + scopeLabel: t('chatInput.dispatch.sessionScope'), + onChange: handleDispatchPermissionModeChange, + onHide: handleHidePermissionModeControl, + } + : { + mode: permissionMode, + saving: permissionModeSaving, + onChange: isAcpTargetSession ? undefined : handlePermissionModeChange, + onHide: isAcpTargetSession ? undefined : handleHidePermissionModeControl, + } + : undefined} usageReport={ effectiveTargetSessionId && effectiveTargetSession && !usesDispatchTransport ? { visible: true, onOpen: handleToolbarUsageReport } diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index bd37689c73..cf4169af83 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -160,6 +160,43 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); }); + it('reuses the permission control with dispatch-scoped choices', async () => { + const onChange = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector( + '[data-testid="chat-input-permission-trigger"]', + ); + expect(trigger?.dataset.permissionMode).toBe('reject'); + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(container.textContent).toContain('This dispatched session'); + expect(container.querySelector( + '[data-testid="chat-input-permission-option-full_access"]', + )).toBeNull(); + + await act(async () => { + container.querySelector( + '[data-testid="chat-input-permission-option-auto"]', + )?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onChange).toHaveBeenCalledWith('auto'); + }); + it('offers the worktree toggle for a Git workspace and reports the new state', async () => { const onChange = vi.fn(async () => undefined); await act(async () => { diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index ab2f288373..6eaaabb3a7 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -47,6 +47,9 @@ export interface ChatInputWorkspaceStripProps { permissionControl?: { mode: ChatInputPermissionMode; saving?: boolean; + disabled?: boolean; + options?: Array>; + scopeLabel?: string; onChange?: (mode: Exclude) => void | Promise; onHide?: () => void | Promise; }; @@ -81,9 +84,9 @@ export interface ChatInputWorkspaceStripProps { }; } -export type ChatInputPermissionMode = 'ask' | 'auto' | 'full_access' | 'acp'; +export type ChatInputPermissionMode = 'ask' | 'auto' | 'full_access' | 'reject' | 'acp'; -const NATIVE_PERMISSION_MODES: Array> = [ +const NATIVE_PERMISSION_MODES: Array> = [ 'ask', 'auto', 'full_access', @@ -154,6 +157,10 @@ export const ChatInputWorkspaceStrip: React.FC = ( label: t('chatInput.permissionMode.fullAccess.label'), description: t('chatInput.permissionMode.fullAccess.description'), }, + reject: { + label: t('chatInput.permissionMode.reject.label'), + description: t('chatInput.permissionMode.reject.description'), + }, acp: { label: t('chatInput.permissionMode.acp.label'), description: t('chatInput.permissionMode.acp.tooltip'), @@ -215,6 +222,11 @@ export const ChatInputWorkspaceStrip: React.FC = ( worktreeTooltip = tWorktrees('strip.toggleOnDescription', { path: trimmedPath }); } const permissionMode = permissionControl?.mode ?? 'ask'; + const permissionModes = permissionControl?.options ?? NATIVE_PERMISSION_MODES; + const permissionDisabled = + permissionControl?.disabled + || permissionControl?.saving + || permissionMode === 'acp'; const permissionModeLabel = permissionCopy[permissionMode].label; const permissionTooltip = permissionMode === 'acp' ? t('chatInput.permissionMode.acp.tooltip') @@ -363,14 +375,16 @@ export const ChatInputWorkspaceStrip: React.FC = ( .filter(Boolean) .join(' ')} aria-label={permissionTooltip} - aria-haspopup={permissionMode === 'acp' ? undefined : 'menu'} - aria-expanded={permissionMode === 'acp' ? undefined : permissionMenuOpen} - disabled={permissionControl.saving || permissionMode === 'acp'} + aria-haspopup={permissionDisabled ? undefined : 'menu'} + aria-expanded={permissionDisabled ? undefined : permissionMenuOpen} + disabled={permissionDisabled} data-testid="chat-input-permission-trigger" data-permission-mode={permissionMode} onClick={event => { event.stopPropagation(); - setPermissionMenuOpen(open => !open); + if (!permissionDisabled) { + setPermissionMenuOpen(open => !open); + } }} > @@ -391,10 +405,12 @@ export const ChatInputWorkspaceStrip: React.FC = ( >
{t('chatInput.permissionMode.menuLabel')} - {t('chatInput.permissionMode.globalScope')} + + {permissionControl.scopeLabel ?? t('chatInput.permissionMode.globalScope')} +
- {NATIVE_PERMISSION_MODES.map(mode => { + {permissionModes.map(mode => { const selected = permissionMode === mode; const copy = permissionCopy[mode]; return ( diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index fc4c36fd05..c72cb05462 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -36,6 +36,15 @@ import './ModelSelector.scss'; const log = createLogger('ModelSelector'); const ACP_SESSION_OPTIONS_TIMEOUT_MS = 65_000; +export interface ExternalModelSelection { + models: string[]; + selectedModelId?: string; + defaultModelId?: string; + providerLabel: string; + disabled?: boolean; + onSelect: (modelId: string) => void | Promise; +} + interface ModelSelectorProps { /** Current target agent type. */ currentMode: string; @@ -55,6 +64,8 @@ interface ModelSelectorProps { contextUsageSource?: ContextUsageSource; /** Called when model switching starts or completes, so the parent can gate sending. */ onLoadingChange?: (loading: boolean) => void; + /** Target-owned model catalog for transports that do not have a local backend session. */ + externalSelection?: ExternalModelSelection; } interface ModelInfo { @@ -173,6 +184,7 @@ export const ModelSelector: React.FC = ({ maxTokens = 0, contextUsageSource, onLoadingChange, + externalSelection, }) => { const { t } = useTranslation('flow-chat'); const [allModels, setAllModels] = useState([]); @@ -406,6 +418,31 @@ export const ModelSelector: React.FC = ({ }; }, [acpAvailableModels, acpClientId, acpOptions?.currentModelId, isAcpSession]); + const externalAvailableModels = useMemo((): ModelInfo[] => { + if (!externalSelection) return []; + return Array.from(new Set([ + ...externalSelection.models, + externalSelection.defaultModelId, + externalSelection.selectedModelId, + ].filter((model): model is string => !!model?.trim()))) + .map(model => ({ + id: model, + configName: model, + modelName: model, + providerName: externalSelection.providerLabel, + provider: 'external', + })); + }, [externalSelection]); + + const externalCurrentModelId = + externalSelection?.selectedModelId?.trim() + || externalSelection?.defaultModelId?.trim() + || externalAvailableModels[0]?.id + || ''; + const externalCurrentModel = externalAvailableModels.find( + model => model.id === externalCurrentModelId, + ) ?? null; + const acpFastMode = useMemo( () => resolveAcpFastModeState(acpOptions?.configOptions ?? []), [acpOptions?.configOptions], @@ -515,6 +552,10 @@ export const ModelSelector: React.FC = ({ setDropdownOpen(false); try { + if (externalSelection) { + await externalSelection.onSelect(modelId); + return; + } if (isAcpSession && acpClientId && sessionId) { const options = await ACPClientAPI.setSessionModel({ sessionId, @@ -578,6 +619,7 @@ export const ModelSelector: React.FC = ({ activeSession?.workspacePath, acpClientId, currentMode, + externalSelection, isAcpSession, loading, sessionId, @@ -698,6 +740,98 @@ export const ModelSelector: React.FC = ({ const resolvedContextUsageSource: ContextUsageSource = contextUsageSource ?? (isAcpSession ? 'acp_context' : 'agent_prompt'); + if (externalSelection) { + if (externalAvailableModels.length === 0) { + return null; + } + + return ( +
+ + + + + {dropdownOpen && createPortal( + , + document.body, + )} +
+ ); + } + if (isAcpSession) { if (acpAvailableModels.length === 0) { return null; diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx new file mode 100644 index 0000000000..6ed55e9995 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -0,0 +1,116 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelSelector } from './ModelSelector'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + Switch: () => null, +})); + +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: { + getConfigs: vi.fn(async () => ({})), + onConfigChange: vi.fn(() => () => undefined), + setConfig: vi.fn(async () => undefined), + }, +})); + +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { updateSessionModel: vi.fn(async () => undefined) }, +})); + +vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ + ACPClientAPI: { + getSessionOptions: vi.fn(), + onSessionOptionsChanged: vi.fn(() => () => undefined), + }, +})); + +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, +})); + +vi.mock('../store/FlowChatStore', () => ({ + FlowChatStore: { + getInstance: () => ({ + getState: () => ({ sessions: new Map() }), + }), + }, +})); + +describe('ModelSelector external transport reuse', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + class TestResizeObserver { + observe() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', TestResizeObserver); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('renders the target catalog through the shared selector and applies a choice', async () => { + const onSelect = vi.fn(async () => undefined); + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const trigger = container.querySelector( + '[data-testid="chat-model-selector-btn"]', + ); + expect(trigger?.textContent).toContain('model-a'); + await act(async () => { + trigger?.click(); + }); + + const modelB = document.body.querySelector( + '[data-testid="chat-model-selector-option"][data-model-id="model-b"]', + ); + await act(async () => { + modelB?.click(); + await Promise.resolve(); + }); + expect(onSelect).toHaveBeenCalledWith('model-b'); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 48ed85c33c..097563d35a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -769,6 +769,8 @@ export async function createChatSession( // Do not inherit the controller's model selector. An omitted target // model lets the probed target use its own configured default. model: config.dispatchModel?.trim() || undefined, + availableModels: config.dispatchAvailableModels, + defaultModel: config.dispatchDefaultModel, cursor: 0, state: 'submitting', appliedEventIds: [], diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 87022c75b8..765f8d9372 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2146,6 +2146,56 @@ export class FlowChatStore { }); } + /** Update the target-owned model choice before an observer job is submitted. */ + public updateSessionDispatchModel(sessionId: string, modelName: string): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + const normalizedModelName = modelName.trim(); + if ( + !session + || !normalizedModelName + || session.config.dispatchModel === normalizedModelName + ) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + config: { + ...session.config, + dispatchModel: normalizedModelName, + }, + lastActiveAt: Date.now(), + }); + return { ...prev, sessions: newSessions }; + }); + } + + /** Update the immutable-at-submit approval policy while the job is still local. */ + public updateSessionDispatchApprovalPolicy( + sessionId: string, + approvalPolicy: NonNullable, + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session || session.config.dispatchApprovalPolicy === approvalPolicy) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + config: { + ...session.config, + dispatchApprovalPolicy: approvalPolicy, + }, + lastActiveAt: Date.now(), + }); + return { ...prev, sessions: newSessions }; + }); + } + /** * Apply a backend session rebind (worktree isolation toggled on or off). * The project root stays put; only the execution directory moves. @@ -2195,6 +2245,9 @@ export class FlowChatStore { target: NonNullable; jobId: string; approvalPolicy: NonNullable; + model?: string; + availableModels?: string[]; + defaultModel?: string; state?: NonNullable; cursor?: number; }, @@ -2226,6 +2279,11 @@ export class FlowChatStore { dispatchTarget: binding.target, dispatchJobId: binding.jobId, dispatchApprovalPolicy: binding.approvalPolicy, + dispatchModel: binding.model ?? session.config.dispatchModel, + dispatchAvailableModels: + binding.availableModels ?? session.config.dispatchAvailableModels, + dispatchDefaultModel: + binding.defaultModel ?? session.config.dispatchDefaultModel, dispatchJobState: binding.state ?? session.config.dispatchJobState ?? 'queued', dispatchCursor: Math.max(0, binding.cursor ?? session.config.dispatchCursor ?? 0), }, diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 4bdc321d0d..a5982a1dd0 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -505,6 +505,10 @@ export interface SessionConfig { dispatchWorkspaceDelivery?: import('@/features/dispatch/types').DispatchWorkspaceDeliveryRequest; /** Target model explicitly selected during preflight; omitted to use the target default. */ dispatchModel?: string; + /** Model ids reported by the selected target during dispatch preflight. */ + dispatchAvailableModels?: string[]; + /** Target-owned default model reported during dispatch preflight. */ + dispatchDefaultModel?: string; /** Last target-side job state applied by the observer. */ dispatchJobState?: import('@/features/dispatch/types').DispatchJobState; /** Byte cursor applied successfully from the target-side event log. */ diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index e8739d3690..e620ac1936 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1455,10 +1455,13 @@ "deliveryTitle": "Workspace delivery", "deliveryExisting": "Use target directory", "deliveryExistingDescription": "Run against a directory that already exists on the target.", + "deliverySourceSnapshot": "Transfer source snapshot", + "deliverySourceSnapshotDescription": "Copy source files once while excluding ignored dependencies, build output, and local secrets.", "deliverySnapshot": "Transfer exact snapshot", "deliverySnapshotDescription": "Copy this workspace once, including hidden and ignored files.", "deliverySnapshotUnavailable": "Exact snapshots require an open local workspace; remote workspaces cannot be captured by this controller.", "snapshotSource": "Source workspace", + "sourceSnapshotHint": "Repository ignore rules are honored, so generated output, dependency caches, and ignored secrets are not transferred. Hidden source files remain included.", "snapshotWarning": "The snapshot includes ignored and hidden regular files. Git metadata is excluded; links and special files are rejected. Changes made on the target are not synced back automatically.", "snapshotConfirm": "I understand that ignored files may contain secrets and approve this one-time transfer.", "snapshotResultLocation": "Where results stay", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 1897f2e4e5..e47eebeb2c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -675,6 +675,10 @@ "label": "Full access", "description": "Tools are allowed by default without confirmation." }, + "reject": { + "label": "Reject requests", + "description": "Stop and report when the task needs confirmation." + }, "acp": { "label": "ACP controlled", "tooltip": "Permissions for this session are controlled by the ACP client." diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 2be228e4c7..a8c1508fc5 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1455,10 +1455,13 @@ "deliveryTitle": "工作区传输", "deliveryExisting": "使用目标目录", "deliveryExistingDescription": "在目标上已存在的目录中运行。", + "deliverySourceSnapshot": "传输源码快照", + "deliverySourceSnapshotDescription": "一次性复制源码,并排除被忽略的依赖、构建产物和本地密钥。", "deliverySnapshot": "传输精确快照", "deliverySnapshotDescription": "一次性复制当前工作区,包括隐藏文件和被忽略文件。", "deliverySnapshotUnavailable": "精确快照需要打开本地工作区;此派发端无法抓取远程工作区。", "snapshotSource": "源工作区", + "sourceSnapshotHint": "遵循仓库忽略规则,不传输生成产物、依赖缓存和被忽略的密钥;隐藏的源码文件仍会包含。", "snapshotWarning": "快照包含被忽略及隐藏的普通文件;不包含 Git 元数据;符号链接和特殊文件会被拒绝。目标上的修改不会自动同步回来。", "snapshotConfirm": "我了解被忽略文件可能包含密钥,并同意本次一次性传输。", "snapshotResultLocation": "结果存放位置", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 14824d9cd3..a60b171c99 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -675,6 +675,10 @@ "label": "完全访问", "description": "默认允许工具执行,无需确认。" }, + "reject": { + "label": "拒绝请求", + "description": "任务需要确认时停止并报告。" + }, "acp": { "label": "ACP 控制", "tooltip": "此会话的权限由 ACP 客户端控制。" diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 1aa2e0d149..36c31fa05f 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1455,10 +1455,13 @@ "deliveryTitle": "工作區傳輸", "deliveryExisting": "使用目標目錄", "deliveryExistingDescription": "在目標上已存在的目錄中執行。", + "deliverySourceSnapshot": "傳輸原始碼快照", + "deliverySourceSnapshotDescription": "一次性複製原始碼,並排除被忽略的依賴、建置產物和本機密鑰。", "deliverySnapshot": "傳輸精確快照", "deliverySnapshotDescription": "一次性複製目前工作區,包括隱藏檔案和被忽略檔案。", "deliverySnapshotUnavailable": "精確快照需要開啟本機工作區;此派發端無法擷取遠端工作區。", "snapshotSource": "來源工作區", + "sourceSnapshotHint": "遵循儲存庫忽略規則,不傳輸產生的輸出、依賴快取和被忽略的密鑰;隱藏的原始碼檔案仍會包含。", "snapshotWarning": "快照包含被忽略及隱藏的一般檔案;不包含 Git 中繼資料;符號連結和特殊檔案會被拒絕。目標上的修改不會自動同步回來。", "snapshotConfirm": "我瞭解被忽略檔案可能包含密鑰,並同意本次一次性傳輸。", "snapshotResultLocation": "結果存放位置", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 4f27eacaf8..0e504fa9fa 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -675,6 +675,10 @@ "label": "完全存取", "description": "預設允許工具執行,無需確認。" }, + "reject": { + "label": "拒絕要求", + "description": "任務需要確認時停止並回報。" + }, "acp": { "label": "ACP 控制", "tooltip": "此工作階段的權限由 ACP 用戶端控制。" From 7f2bee03de2b4a56935d67a5419e93759c50f512 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 21:25:38 -0700 Subject: [PATCH 03/11] fix(dispatch): present synced models by name --- .../dispatch/DispatchInstallDialog.test.tsx | 4 +-- .../dispatch/DispatchInstallDialog.tsx | 2 +- .../flow_chat/components/ModelSelector.tsx | 35 ++++++++++++++----- .../components/ModelSelectorExternal.test.tsx | 17 +++++++-- src/web-ui/src/locales/en-US/common.json | 2 +- src/web-ui/src/locales/zh-CN/common.json | 2 +- src/web-ui/src/locales/zh-TW/common.json | 2 +- 7 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 722850d96b..09cd4a8e48 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -516,7 +516,7 @@ describe('DispatchInstallDialog model configuration sync', () => { container.remove(); }); - it('offers the sync only while the target CLI answers without a usable model', async () => { + it('keeps model sync available after the target reports a usable model', async () => { await mount(); expect(syncButton()).toBeDefined(); @@ -536,7 +536,7 @@ describe('DispatchInstallDialog model configuration sync', () => { expect(mocks.syncModelConfig).toHaveBeenCalledWith('ssh-1'); // The sync re-probes so the model check reflects the target, not the write. expect(mocks.probeTarget.mock.calls.length).toBeGreaterThan(probesBeforeSync); - expect(syncButton()).toBeUndefined(); + expect(syncButton()).toBeDefined(); }); it('does not write the credential-bearing config when the confirmation is declined', async () => { diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index be289789d7..f44018e293 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -725,7 +725,7 @@ export const DispatchInstallDialog: React.FC = ({ ) : null} - {target?.kind === 'ssh' && probe?.protocol && !modelReady ? ( + {target?.kind === 'ssh' && probe?.protocol ? (

diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index c72cb05462..5d7763e0d8 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -425,14 +425,33 @@ export const ModelSelector: React.FC = ({ externalSelection.defaultModelId, externalSelection.selectedModelId, ].filter((model): model is string => !!model?.trim()))) - .map(model => ({ - id: model, - configName: model, - modelName: model, - providerName: externalSelection.providerLabel, - provider: 'external', - })); - }, [externalSelection]); + .map(modelId => { + // A synced target reports stable config ids because those are what the + // worker must execute. Reuse the controller catalog for presentation + // so generated ids never leak into the normal model-picker UI. + const localModel = allModels.find(model => model.id === modelId); + return localModel + ? { + id: modelId, + configName: localModel.name, + modelName: localModel.model_name, + providerName: getProviderDisplayName(localModel), + provider: localModel.provider, + contextWindow: localModel.context_window, + enableThinking: isReasoningVisiblyEnabled( + getEffectiveReasoningMode(localModel), + ), + reasoningEffort: localModel.reasoning_effort, + } + : { + id: modelId, + configName: modelId, + modelName: modelId, + providerName: externalSelection.providerLabel, + provider: 'external', + }; + }); + }, [allModels, externalSelection]); const externalCurrentModelId = externalSelection?.selectedModelId?.trim() diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 6ed55e9995..101a46e885 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -24,7 +24,20 @@ vi.mock('@/component-library', () => ({ vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { - getConfigs: vi.fn(async () => ({})), + getConfigs: vi.fn(async () => ({ + 'ai.models': [ + { + id: 'model-a', + name: 'Synced provider', + model_name: 'friendly-model-a', + provider: 'openai', + base_url: 'https://example.test/v1', + enabled: true, + category: 'text', + capabilities: ['text_chat'], + }, + ], + })), onConfigChange: vi.fn(() => () => undefined), setConfig: vi.fn(async () => undefined), }, @@ -99,7 +112,7 @@ describe('ModelSelector external transport reuse', () => { const trigger = container.querySelector( '[data-testid="chat-model-selector-btn"]', ); - expect(trigger?.textContent).toContain('model-a'); + expect(trigger?.textContent).toContain('friendly-model-a'); await act(async () => { trigger?.click(); }); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index e620ac1936..ec9cc45221 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1493,7 +1493,7 @@ "modelAutomatic": "target default", "modelMissing": "No usable model is configured on the target", "syncModelRequired": "Sync model configuration", - "syncModelDescription": "Copy this device's model configuration (including API credentials) to the target.", + "syncModelDescription": "Replace the target's model catalog and defaults with this device's configuration, including API credentials.", "syncModelConfirmTitle": "Sync model configuration to this target?", "syncModelConfirmMessage": "This device's model catalog and default model selections, including API credentials, will be written to the target user's BitFun config file with owner-only permissions.", "syncModelConfirm": "Sync", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index a8c1508fc5..42bd941ab2 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1493,7 +1493,7 @@ "modelAutomatic": "目标默认模型", "modelMissing": "目标上没有可用的模型配置", "syncModelRequired": "同步模型配置", - "syncModelDescription": "将本机的模型配置(含 API 密钥)复制到目标。", + "syncModelDescription": "使用本机模型配置(含 API 密钥)替换目标端的模型列表与默认选择。", "syncModelConfirmTitle": "同步模型配置到此目标?", "syncModelConfirmMessage": "本机的模型列表与默认模型选择(包含 API 密钥)将写入目标用户的 BitFun 配置文件,且仅目标用户可读。", "syncModelConfirm": "同步", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 36c31fa05f..4cee48dde9 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1493,7 +1493,7 @@ "modelAutomatic": "目標預設模型", "modelMissing": "目標上沒有可用的模型設定", "syncModelRequired": "同步模型設定", - "syncModelDescription": "將本機的模型設定(含 API 金鑰)複製到目標。", + "syncModelDescription": "使用本機模型設定(含 API 金鑰)取代目標端的模型清單與預設選擇。", "syncModelConfirmTitle": "同步模型設定到此目標?", "syncModelConfirmMessage": "本機的模型清單與預設模型選擇(包含 API 金鑰)將寫入目標使用者的 BitFun 設定檔,且僅目標使用者可讀。", "syncModelConfirm": "同步", From d0fc8596c7bd5f0f9b5b444315f37bfbbc3da1c7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 21:48:10 -0700 Subject: [PATCH 04/11] fix(dispatch): gate input while submitting --- src/apps/cli/src/root_handlers.rs | 6 ++ .../dispatch/dispatchPreflight.test.ts | 17 +++-- .../features/dispatch/dispatchPreflight.ts | 12 ---- .../src/flow_chat/components/ChatInput.tsx | 72 ++++++++----------- .../modern/RuntimeStatusSlot.test.tsx | 10 +++ .../components/modern/RuntimeStatusSlot.tsx | 4 +- .../src/flow_chat/hooks/useMessageSender.ts | 5 -- .../src/flow_chat/services/FlowChatManager.ts | 2 - .../flow-chat-manager/MessageModule.test.ts | 35 +++++---- .../flow-chat-manager/MessageModule.ts | 48 +++++++++---- .../src/flow_chat/store/runtimeStatusStore.ts | 2 + src/web-ui/src/locales/en-US/flow-chat.json | 6 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 6 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 6 +- 14 files changed, 121 insertions(+), 110 deletions(-) diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 5bced4b5ee..5dfbb2f914 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -44,6 +44,12 @@ pub(crate) struct ExecCommandArgs { } pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> { + // Dispatch verbs may initialize global configuration before a detached + // worker builds its runtime. Select the CLI profile at the process entry + // point so config canonicalization cannot lazily claim product-full first. + crate::agent::agentic_system::select_agentic_system_profile( + bitfun_core::product_assembly::DeliveryProfile::Cli, + )?; let verb = match action { DispatchAction::Run { job } => return crate::dispatch::run_worker(job).await, DispatchAction::WorkspaceMaterialize { job } => { diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts index 897805df3d..930baa110a 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { shouldConfirmDispatchAutoApproval } from './dispatchPreflight'; +import { isDispatchWorkspaceReady } from './dispatchPreflight'; describe('dispatch preflight', () => { - it('confirms auto approval only for submit and ambiguous submit retry', () => { - expect(shouldConfirmDispatchAutoApproval('auto', 'submitting')).toBe(true); - expect(shouldConfirmDispatchAutoApproval('auto', 'submission_unknown')).toBe(true); - expect(shouldConfirmDispatchAutoApproval('auto', 'queued')).toBe(false); - expect(shouldConfirmDispatchAutoApproval('auto', 'running')).toBe(false); - expect(shouldConfirmDispatchAutoApproval('remote', 'submitting')).toBe(false); + it('accepts only the exact probed target workspace', () => { + const workspace = { + path: '/srv/app', + exists: true, + isDirectory: true, + isGitRepository: true, + }; + expect(isDispatchWorkspaceReady('/srv/app', workspace)).toBe(true); + expect(isDispatchWorkspaceReady('/srv/other', workspace)).toBe(false); }); }); diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index 9e4494a228..8c2129e844 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -1,6 +1,4 @@ import type { - DispatchApprovalPolicy, - DispatchJobState, DispatchWorkspaceProbe, } from './types'; @@ -14,16 +12,6 @@ export const BASE_DISPATCH_CAPABILITIES = [ 'workspace_serialization', ] as const; -export function shouldConfirmDispatchAutoApproval( - policy: DispatchApprovalPolicy | undefined, - state: DispatchJobState | undefined, -): boolean { - return ( - policy === 'auto' - && (state === 'submitting' || state === 'submission_unknown') - ); -} - export function isDispatchWorkspaceReady( workspacePath: string, workspace: DispatchWorkspaceProbe | undefined, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 1f6bf59a5b..d0d7481120 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -132,8 +132,8 @@ import { } from './ChatInputWorkspaceStrip'; import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; -import { shouldConfirmDispatchAutoApproval } from '@/features/dispatch/dispatchPreflight'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; import { useComposerVoiceInput } from './voice/useComposerVoiceInput'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; @@ -508,6 +508,13 @@ export const ChatInput: React.FC = ({ effectiveTargetSession?.config.dispatchTarget, ); const usesDispatchTransport = !registration && isDispatchInputSession; + const dispatchSubmissionInFlight = useRuntimeStatusStore(state => { + const status = effectiveTargetSessionId + ? state.bySessionId.get(effectiveTargetSessionId) + : undefined; + return usesDispatchTransport + && status?.roundId.startsWith('dispatch-transfer:') === true; + }); const historySessionOpenTransition = useSyncExternalStore( subscribeHistorySessionOpenTransition, getHistorySessionOpenTransitionSnapshot, @@ -2004,8 +2011,11 @@ export const ChatInput: React.FC = ({ ? 'reject' : 'ask'; const dispatchSubmissionOptionsLocked = - effectiveTargetSession?.config.dispatchJobState !== 'submitting' - && effectiveTargetSession?.config.dispatchJobState !== 'submission_unknown'; + dispatchSubmissionInFlight + || ( + effectiveTargetSession?.config.dispatchJobState !== 'submitting' + && effectiveTargetSession?.config.dispatchJobState !== 'submission_unknown' + ); const handleDispatchPermissionModeChange = useCallback(( nextMode: Exclude, ) => { @@ -2175,8 +2185,11 @@ export const ChatInput: React.FC = ({ defaultModelId: effectiveTargetSession.config.dispatchDefaultModel, providerLabel, disabled: - state !== 'submitting' - && state !== 'submission_unknown', + dispatchSubmissionInFlight + || ( + state !== 'submitting' + && state !== 'submission_unknown' + ), onSelect: (modelId: string) => { FlowChatStore.getInstance().updateSessionDispatchModel(sessionId, modelId); if (jobId) { @@ -2184,7 +2197,7 @@ export const ChatInput: React.FC = ({ } }, }; - }, [effectiveTargetSession, t, usesDispatchTransport]); + }, [dispatchSubmissionInFlight, effectiveTargetSession, t, usesDispatchTransport]); const handleHidePermissionModeControl = useCallback(async () => { try { @@ -3863,6 +3876,7 @@ export const ChatInput: React.FC = ({ const handleSendOrCancel = useCallback(async (messageOverride?: string) => { if (!derivedState) return; + if (dispatchSubmissionInFlight) return; const { sendButtonMode } = derivedState; const draftTrimmed = (messageOverride ?? inputState.value).trim(); @@ -4011,32 +4025,6 @@ export const ChatInput: React.FC = ({ return; } - let dispatchAutoConfirmed = false; - if ( - usesDispatchTransport && - shouldConfirmDispatchAutoApproval( - effectiveTargetSession?.config.dispatchApprovalPolicy, - effectiveTargetSession?.config.dispatchJobState, - ) - ) { - const dispatchTarget = effectiveTargetSession.config.dispatchTarget; - const targetLabel = - dispatchTarget?.kind === 'ssh' || dispatchTarget?.kind === 'device' - ? dispatchTarget.displayName - : t('chatInput.dispatch.remoteTarget'); - dispatchAutoConfirmed = await confirmWarning( - t('chatInput.dispatch.autoConfirmTitle'), - t('chatInput.dispatch.autoConfirmMessage', { target: targetLabel }), - { - confirmText: t('chatInput.dispatch.autoConfirmAction'), - cancelText: t('chatInput.dispatch.autoConfirmCancel'), - }, - ); - if (!dispatchAutoConfirmed) { - return; - } - } - // Add to history before clearing (session-scoped) if (effectiveTargetSessionId) { addToHistory(effectiveTargetSessionId, message); @@ -4066,7 +4054,6 @@ export const ChatInput: React.FC = ({ () => sendMessage(message, { displayMessage: originalMessage, composerPresentation: persistedComposerPresentation, - dispatchAutoConfirmed, }), ); if (transport === 'registered') { @@ -4099,6 +4086,7 @@ export const ChatInput: React.FC = ({ } }, [ isModelSwitching, + dispatchSubmissionInFlight, inputState.value, derivedState, dispatchInput, @@ -4112,9 +4100,6 @@ export const ChatInput: React.FC = ({ onSendMessage, addToHistory, effectiveTargetSessionId, - effectiveTargetSession?.config.dispatchApprovalPolicy, - effectiveTargetSession?.config.dispatchJobState, - effectiveTargetSession?.config.dispatchTarget, clearPendingLargePastes, expandComposerSpecialTokens, isAcpInputSession, @@ -4973,7 +4958,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={isModelSwitching} + disabled={isModelSwitching || dispatchSubmissionInFlight} tooltip={t('input.retry')} size="small" > @@ -4999,7 +4984,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching} + disabled={!inputState.value.trim() || isModelSwitching || dispatchSubmissionInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5014,7 +4999,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching} + disabled={!inputState.value.trim() || isModelSwitching || dispatchSubmissionInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5051,8 +5036,9 @@ export const ChatInput: React.FC = ({ >
{recommendationContext && ( = ({ onCompositionStart={handleImeCompositionStart} onCompositionEnd={handleImeCompositionEnd} placeholder="" - disabled={false} + disabled={dispatchSubmissionInFlight} contexts={contexts} onRemoveContext={removeContext} onMentionStateChange={setMentionState} @@ -5724,7 +5710,9 @@ export const ChatInput: React.FC = ({
) : null} - + {!dispatchSubmissionInFlight ? ( + + ) : null} {voiceInput.phase === 'idle' ? renderActionButton() : null}

diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx index 5166911ca0..e84aaf4f64 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx @@ -52,6 +52,16 @@ describe('RuntimeStatusSlot', () => { expect(slot?.dataset.runtimeStatusVisible).toBe('true'); expect(slot?.textContent).toContain('Working on it'); + act(() => { + useRuntimeStatusStore.getState().show({ + sessionId: 'session-1', + turnId: 'dispatch-turn', + roundId: 'dispatch-transfer:job-1', + label: 'Transferring workspace', + }); + }); + expect(slot?.textContent).toContain('Transferring workspace'); + act(() => { useRuntimeStatusStore.getState().clear({ sessionId: 'session-1' }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx index 32bd7e7772..111614ae52 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx @@ -30,7 +30,9 @@ export const RuntimeStatusSlot: React.FC = ({ ? rawHints.filter((item): item is string => typeof item === 'string') : []; const hint = status - ? hints[stableHintIndex(`${status.turnId}:${status.roundId}`, hints.length)] ?? '' + ? status.label + || hints[stableHintIndex(`${status.turnId}:${status.roundId}`, hints.length)] + || '' : ''; const visible = Boolean(status && hint); diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index ecba543b41..062ee1a7ed 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -61,8 +61,6 @@ interface UseMessageSenderReturn { options?: { displayMessage?: string; composerPresentation?: ComposerPresentation | null; - /** One-shot UI confirmation for unattended auto approval. */ - dispatchAutoConfirmed?: boolean; } ) => Promise; /** Whether a send is in progress */ @@ -86,8 +84,6 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender options?: { displayMessage?: string; composerPresentation?: ComposerPresentation | null; - /** One-shot UI confirmation for unattended auto approval. */ - dispatchAutoConfirmed?: boolean; } ) => { if (!message.trim()) { @@ -201,7 +197,6 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender { ...(imagePayload ?? {}), ...(userMessageMetadata ? { userMessageMetadata } : {}), - ...(options?.dispatchAutoConfirmed ? { dispatchAutoConfirmed: true } : {}), onSessionConflictRetryStart: () => { onSessionConflictRetryStart?.({ sessionId: sessionId!, diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 0ae865dded..7353cf62cd 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -664,8 +664,6 @@ export class FlowChatManager { userMessageMetadata?: Record; turnId?: string; preserveTurnOnStartError?: boolean; - /** One-shot UI confirmation for unattended auto approval. */ - dispatchAutoConfirmed?: boolean; onSessionConflictRetryStart?: () => void; onSessionConflictRetrySuccess?: () => void; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 1bdf2829a8..4565dd205e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cancelSessionTask, sendMessage, syncSessionModelSelection } from './MessageModule'; import { SessionExecutionEvent } from '../../state-machine/types'; +import { + getRuntimeStatus, + resetRuntimeStatuses, +} from '../../store/runtimeStatusStore'; const mockTransition = vi.fn(); const mockGetCurrentState = vi.fn(() => 'processing'); @@ -346,6 +350,7 @@ describe('MessageModule session writer conflict', () => { describe('MessageModule cancellation', () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeStatuses(); mockGetCurrentState.mockReturnValue('processing'); mockTransition.mockResolvedValue(true); }); @@ -463,6 +468,10 @@ describe('MessageModule detached dispatch', () => { it('projects the user message immediately while the target is still queued', async () => { const { context, session } = createDispatchContext('reject-and-report'); + (session.config as any).dispatchWorkspaceDelivery = { + kind: 'snapshot-source', + sourceWorkspacePath: '/controller/repo', + }; let resolveSubmit!: (value: { accepted: boolean; jobId: string; @@ -494,6 +503,11 @@ describe('MessageModule detached dispatch', () => { modelRounds: [], status: 'pending', }); + expect(getRuntimeStatus('dispatch-session')).toMatchObject({ + turnId: 'dispatch_pending_job-1', + roundId: 'dispatch-transfer:job-1', + label: 'flow-chat:chatInput.dispatch.transferInProgress', + }); resolveSubmit({ accepted: true, jobId: 'job-1', @@ -501,6 +515,7 @@ describe('MessageModule detached dispatch', () => { state: 'queued', }); await submission; + expect(getRuntimeStatus('dispatch-session')).toBeUndefined(); expect(mockDispatchSubmit).toHaveBeenCalledWith({ target: { @@ -508,7 +523,10 @@ describe('MessageModule detached dispatch', () => { connectionId: 'ssh-1', workspacePath: '/target/repo', }, - workspaceDelivery: { kind: 'existing' }, + workspaceDelivery: { + kind: 'snapshot-source', + sourceWorkspacePath: '/controller/repo', + }, jobId: 'job-1', sessionId: 'dispatch-session', agentType: 'agentic', @@ -531,24 +549,11 @@ describe('MessageModule detached dispatch', () => { expect(session.dialogTurns).toEqual([]); }); - it('requires a one-shot auto-approval confirmation before the actual submit', async () => { + it('submits with the session-scoped auto-approval setting without another confirmation', async () => { const { context } = createDispatchContext('auto'); await expect( sendMessage(context, 'run remote checks', 'dispatch-session'), - ).rejects.toThrow('requires an explicit confirmation'); - expect(mockDispatchSubmit).not.toHaveBeenCalled(); - - await expect( - sendMessage( - context, - 'run remote checks', - 'dispatch-session', - undefined, - undefined, - undefined, - { dispatchAutoConfirmed: true }, - ), ).resolves.toBeUndefined(); expect(mockDispatchSubmit).toHaveBeenCalledTimes(1); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 3ac3d443e1..7a05497ef0 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -32,6 +32,10 @@ import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; import { i18nService } from '@/infrastructure/i18n'; +import { + clearRuntimeStatusState, + showRuntimeStatus, +} from '@/flow_chat/store/runtimeStatusStore'; const log = createLogger('MessageModule'); @@ -201,8 +205,6 @@ export async function sendMessage( userMessageMetadata?: Record; turnId?: string; preserveTurnOnStartError?: boolean; - /** One-shot UI confirmation for unattended auto approval. Never persist this flag. */ - dispatchAutoConfirmed?: boolean; onSessionConflictRetryStart?: () => void; onSessionConflictRetrySuccess?: () => void; fromSessionConflictRetry?: boolean; @@ -372,9 +374,6 @@ export async function sendMessage( ) { throw new Error('This detached dispatch job has already been submitted'); } - if (approvalPolicy === 'auto' && options?.dispatchAutoConfirmed !== true) { - throw new Error('Auto-approval dispatch requires an explicit confirmation before submit'); - } if (isFirstMessage) { handleTitleGeneration(context, sessionId, message); } @@ -411,17 +410,38 @@ export async function sendMessage( 'MessageModule', ); - const response = await dispatchApi.submit({ - target: targetRequest, - workspaceDelivery: - readySession.config.dispatchWorkspaceDelivery ?? { kind: 'existing' }, - jobId, + const workspaceDelivery = + readySession.config.dispatchWorkspaceDelivery ?? { kind: 'existing' as const }; + const transferRoundId = `dispatch-transfer:${jobId}`; + showRuntimeStatus({ sessionId, - agentType: currentAgentType, - prompt: message, - approvalPolicy, - model: readySession.config.dispatchModel?.trim() || undefined, + turnId: optimisticTurnId, + roundId: transferRoundId, + label: i18nService.t( + workspaceDelivery.kind === 'existing' + ? 'flow-chat:chatInput.dispatch.submissionInProgress' + : 'flow-chat:chatInput.dispatch.transferInProgress', + ), }); + let response: Awaited>; + try { + response = await dispatchApi.submit({ + target: targetRequest, + workspaceDelivery, + jobId, + sessionId, + agentType: currentAgentType, + prompt: message, + approvalPolicy, + model: readySession.config.dispatchModel?.trim() || undefined, + }); + } finally { + clearRuntimeStatusState({ + sessionId, + turnId: optimisticTurnId, + roundId: transferRoundId, + }); + } if (!response.accepted || response.jobId !== jobId || response.sessionId !== sessionId) { throw new Error('Dispatch target returned a mismatched acknowledgement'); } diff --git a/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts b/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts index 6f51cb5e24..05c81a295d 100644 --- a/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts +++ b/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts @@ -4,6 +4,8 @@ export interface RuntimeStatusEntry { sessionId: string; turnId: string; roundId: string; + /** Optional operation-specific progress label instead of a rotating model hint. */ + label?: string; } export interface RuntimeStatusFilter { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index e47eebeb2c..2e9be3e675 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -650,10 +650,8 @@ "deviceOffline": "Offline", "createFailed": "Could not create the dispatched task.", "remoteTarget": "the remote target", - "autoConfirmTitle": "Allow unattended changes?", - "autoConfirmMessage": "This task will run on {{target}} and automatically approve permission requests. Continue?", - "autoConfirmAction": "Dispatch with auto approval", - "autoConfirmCancel": "Cancel" + "submissionInProgress": "Submitting the task to the target…", + "transferInProgress": "Preparing and transferring the workspace to the target…" }, "addBoostTooltip": "Agent modes, image, or skills", "permissionMode": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index a60b171c99..7fdf857517 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -650,10 +650,8 @@ "deviceOffline": "离线", "createFailed": "无法创建派发任务。", "remoteTarget": "远程目标", - "autoConfirmTitle": "允许无人值守地修改吗?", - "autoConfirmMessage": "此任务将在 {{target}} 上运行,并自动批准权限请求。是否继续?", - "autoConfirmAction": "自动批准并派发", - "autoConfirmCancel": "取消" + "submissionInProgress": "正在向目标提交任务…", + "transferInProgress": "正在准备并传输工作区到目标…" }, "addBoostTooltip": "智能体模式、图片或 Skill", "permissionMode": { diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 0e504fa9fa..69e65ea81c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -650,10 +650,8 @@ "deviceOffline": "離線", "createFailed": "無法建立派發任務。", "remoteTarget": "遠端目標", - "autoConfirmTitle": "允許無人值守地修改嗎?", - "autoConfirmMessage": "此任務將在 {{target}} 上執行,並自動核准權限要求。是否繼續?", - "autoConfirmAction": "自動核准並派發", - "autoConfirmCancel": "取消" + "submissionInProgress": "正在向目標提交任務…", + "transferInProgress": "正在準備並傳輸工作區到目標…" }, "addBoostTooltip": "智能體模式、圖片或 Skill", "permissionMode": { From e49c78ae7c6a0f22c6d2db54df0afd429bc7955f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 21:57:05 -0700 Subject: [PATCH 05/11] fix(dispatch): keep projections in their source workspace --- .../core/src/service/dispatch/controller.rs | 9 +++ .../src/service/dispatch/device_controller.rs | 4 ++ .../assembly/core/src/service/dispatch/mod.rs | 36 ++++++++++- .../dispatch/DispatchJobObserver.test.ts | 60 +++++++++++++++++++ .../features/dispatch/DispatchJobObserver.ts | 27 ++++++--- src/web-ui/src/features/dispatch/README.md | 8 ++- .../src/features/dispatch/dispatchApi.ts | 2 + .../dispatch/dispatchJobStore.test.ts | 36 +++++++++++ .../src/features/dispatch/dispatchJobStore.ts | 13 +++- src/web-ui/src/features/dispatch/types.ts | 3 + .../flow-chat-manager/MessageModule.test.ts | 8 +++ .../flow-chat-manager/MessageModule.ts | 12 ++++ .../src/flow_chat/store/FlowChatStore.ts | 13 ++++ .../flow_chat/utils/sessionOrdering.test.ts | 16 +++++ .../src/flow_chat/utils/sessionOrdering.ts | 13 ++-- 15 files changed, 245 insertions(+), 15 deletions(-) diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index ee3038324c..ab39eefbd0 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -65,6 +65,11 @@ pub struct DispatchSubmitRequest { pub model: Option, #[serde(default)] pub title: Option, + /// Controller-side workspace that owns the observer session. + #[serde(default)] + pub source_workspace_path: Option, + #[serde(default)] + pub source_workspace_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -339,6 +344,10 @@ pub async fn submit( request.agent_type.clone(), request.approval_policy.clone(), request.model.clone(), + ) + .with_source_workspace( + request.source_workspace_path.clone(), + request.source_workspace_id.clone(), ); let bound_record = store.bind_if_absent(&requested_record).await?; if bound_record.session_id != request.session_id diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index ff9999f661..acfae98b14 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -149,6 +149,10 @@ pub async fn submit_device( request.agent_type.clone(), request.approval_policy.clone(), request.model.clone(), + ) + .with_source_workspace( + request.source_workspace_path.clone(), + request.source_workspace_id.clone(), ); let bound_record = store.bind_if_absent(&requested_record).await?; if bound_record.session_id != request.session_id diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index bf0b2e929f..4087f1eea1 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -103,6 +103,11 @@ pub struct OutboundDispatchRecord { pub job_id: String, pub target: DispatchTarget, pub session_id: String, + /// Controller-side workspace that owns the observer session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_id: Option, pub workspace_path: String, pub prompt_preview: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -134,6 +139,8 @@ impl OutboundDispatchRecord { job_id, target, session_id, + source_workspace_path: None, + source_workspace_id: None, workspace_path, prompt_preview: prompt.chars().take(PROMPT_PREVIEW_CHARS).collect(), title: None, @@ -160,6 +167,16 @@ impl OutboundDispatchRecord { self.model = model.filter(|value| !value.trim().is_empty()); self } + + pub fn with_source_workspace( + mut self, + source_workspace_path: Option, + source_workspace_id: Option, + ) -> Self { + self.source_workspace_path = source_workspace_path.filter(|value| !value.trim().is_empty()); + self.source_workspace_id = source_workspace_id.filter(|value| !value.trim().is_empty()); + self + } } #[derive(Debug, Error)] @@ -724,9 +741,26 @@ mod tests { "Summarize the repository", "queued", ) - .expect("record"); + .expect("record") + .with_source_workspace( + Some("/Users/test/projects/BitFun".to_string()), + Some("workspace-1".to_string()), + ); store.bind_if_absent(&record).await.expect("persist"); + let persisted = store + .get("job-1") + .await + .expect("load persisted record") + .expect("persisted record"); + assert_eq!( + persisted.source_workspace_path.as_deref(), + Some("/Users/test/projects/BitFun") + ); + assert_eq!( + persisted.source_workspace_id.as_deref(), + Some("workspace-1") + ); let first = store .update_progress("job-1", 42, "running") .await diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index ffb86a6a9e..3088e3e33d 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + DISPATCH_JOB_POLL_INTERVAL_MS, dispatchEventId, installDispatchJobObserver, projectDispatchAgentEvent, @@ -415,6 +416,65 @@ describe('DispatchJobObserver', () => { cleanup(); }); + it('waits for workspace ownership before restoring a legacy projection', async () => { + const record = { + jobId: 'job-restored', + sessionId: 'session-restored', + target: { + kind: 'ssh' as const, + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + workspacePath: '/target/repo', + promptPreview: 'Dispatch test', + title: 'Dispatch test', + agentType: 'agentic', + approvalPolicy: 'reject-and-report' as const, + lastCursor: 0, + lastState: 'running' as const, + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }; + mocks.listJobs.mockResolvedValue([record]); + mocks.status.mockResolvedValue(status()); + const sessions = new Map(); + const context = createContext(); + context.currentWorkspacePath = null; + context.flowChatStore.getState = vi.fn(() => ({ sessions })); + context.flowChatStore.addExternalSession = vi.fn(( + sessionId: string, + _title: string, + _mode: string, + workspacePath: string, + meta: { projectWorkspacePath: string }, + ) => { + sessions.set(sessionId, { + sessionId, + workspacePath, + projectWorkspacePath: meta.projectWorkspacePath, + config: {}, + }); + }); + + const cleanup = installDispatchJobObserver(context); + await vi.advanceTimersByTimeAsync(0); + expect(context.flowChatStore.addExternalSession).not.toHaveBeenCalled(); + + context.currentWorkspacePath = '/projects/BitFun'; + await vi.advanceTimersByTimeAsync(DISPATCH_JOB_POLL_INTERVAL_MS); + expect(context.flowChatStore.addExternalSession).toHaveBeenCalledWith( + 'session-restored', + 'Dispatch test', + 'agentic', + '/projects/BitFun', + expect.objectContaining({ + projectWorkspacePath: '/projects/BitFun', + }), + ); + cleanup(); + }); + it('continues polling while hidden so background notifications can observe changes', async () => { registerRunningJob(); mocks.status.mockResolvedValue(status({ state: 'running' })); diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index fb535fac0b..b860993316 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -160,12 +160,15 @@ export function dispatchEventId(event: DispatchEvent): string { } function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): boolean { + const sourceWorkspacePath = + job.sourceWorkspacePath?.trim() + || context.currentWorkspacePath?.trim() + || undefined; const existing = context.flowChatStore.getState().sessions.get(job.sessionId); if (existing) { - // The immutable target identity is selected before submit, but the target - // may return a canonical or managed workspace path only after preflight - // and snapshot materialization. Reconcile that authoritative path without - // creating or restoring a controller-side backend session. + // Reconcile both immutable target identity and controller-side ownership. + // The observer can start before FlowChat knows its workspace, so a legacy + // outbound record may only gain its source path on a later poll. context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { targetRequest: job.targetRequest, target: job.target, @@ -176,22 +179,30 @@ function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): b defaultModel: job.defaultModel, state: job.state, cursor: job.cursor, + sourceWorkspacePath, + sourceWorkspaceId: job.sourceWorkspaceId, }); return true; } + // Never create a workspace-less projection. SessionsSection renders once + // per workspace, and an unowned projection must not be allowed to appear in + // every navigation group while startup workspace state is still loading. + if (!sourceWorkspacePath) { + return false; + } + // The persisted cursor represents a transcript that lived only in the old // renderer process. Rebuild a fresh in-memory projection by replaying from // byte zero; never skip straight to that cursor. dispatchJobStore.getState().resetReplay(job.jobId); - const workspacePath = job.sourceWorkspacePath || context.currentWorkspacePath || undefined; context.flowChatStore.addExternalSession( job.sessionId, job.title, job.agentType, - workspacePath, + sourceWorkspacePath, { - projectWorkspacePath: workspacePath, + projectWorkspacePath: sourceWorkspacePath, workspaceId: job.sourceWorkspaceId, }, ); @@ -205,6 +216,8 @@ function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): b defaultModel: job.defaultModel, state: job.state, cursor: 0, + sourceWorkspacePath, + sourceWorkspaceId: job.sourceWorkspaceId, }); return context.flowChatStore.getState().sessions.has(job.sessionId); } diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 0b65f84fa1..5cfde3deaa 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -24,8 +24,8 @@ dispatch. offline target never falls back to local execution. 8. Approval policy is explicit per job: `auto`, `reject-and-report`, or `remote`. `remote` projects pending requests into the normal permission - panel; `auto` requires a one-shot, non-persisted confirmation immediately - before submit. + panel. The selected policy is visible in the normal session controls; submit + must not add a second confirmation dialog. 9. MiniApp and quick-input hosts do not expose the dispatch picker. 10. Controller-side model settings never leak into an SSH dispatch. The submit omits `model` unless preflight recorded an explicit target model choice. @@ -59,3 +59,7 @@ dispatch. 20. The controller projects the initial user turn before waiting for target startup. The target's `DialogTurnStarted` event adopts that pending turn in place so queued work is visible without duplicating the message. +21. Every outbound observer record carries its controller-side source workspace + identity. Legacy records wait for a concrete workspace fallback before + creating a projection; a workspace-less projection must never match every + navigation group. diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index 3f180b1c26..e6497b6b77 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -103,6 +103,8 @@ export const dispatchApi = { approvalPolicy: DispatchApprovalPolicy; model?: string; title?: string; + sourceWorkspacePath?: string; + sourceWorkspaceId?: string; }): Promise { return api.invoke('dispatch_submit', { request, diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts index dd481314b0..609dffe546 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts @@ -136,6 +136,8 @@ describe('dispatchJobStore', () => { agentType: 'debug', approvalPolicy: 'remote', model: 'configured-model', + sourceWorkspacePath: '/controller/repo', + sourceWorkspaceId: 'workspace-1', lastCursor: 900, lastState: 'running', createdAt: '2026-07-28T00:00:00Z', @@ -147,10 +149,44 @@ describe('dispatchJobStore', () => { agentType: 'debug', approvalPolicy: 'remote', model: 'configured-model', + sourceWorkspacePath: '/controller/repo', + sourceWorkspaceId: 'workspace-1', cursor: 0, }); }); + it('backfills a legacy outbound job after the source workspace initializes', () => { + const record = { + jobId: 'job-restored', + sessionId: 'session-restored', + target: { + kind: 'ssh' as const, + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + workspacePath: '/target/repo', + promptPreview: 'Prompt preview', + lastCursor: 0, + lastState: 'running' as const, + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }; + + dispatchJobStore.getState().mergeOutboundRecords([record]); + expect( + dispatchJobStore.getState().jobs['job-restored'].sourceWorkspacePath + ).toBeUndefined(); + + dispatchJobStore.getState().mergeOutboundRecords( + [record], + '/projects/BitFun', + ); + expect( + dispatchJobStore.getState().jobs['job-restored'].sourceWorkspacePath + ).toBe('/projects/BitFun'); + }); + it('persists a dismissal tombstone so reconciliation cannot reopen the projection', () => { registerJob(); dispatchJobStore.getState().dismissJob('job-1'); diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index f49f525612..c1532309ba 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -178,6 +178,15 @@ export const useDispatchJobStore = create()( ...existing, target: record.target, targetRequest: requestFromTarget(record.target), + // The durable outbound record is authoritative when it knows + // the source. Legacy records are backfilled once FlowChat has + // initialized a concrete controller workspace. + sourceWorkspacePath: + record.sourceWorkspacePath + || existing.sourceWorkspacePath + || fallbackSourceWorkspacePath, + sourceWorkspaceId: + record.sourceWorkspaceId || existing.sourceWorkspaceId, title: record.title || existing.title, agentType: record.agentType || existing.agentType, approvalPolicy: record.approvalPolicy || existing.approvalPolicy, @@ -197,7 +206,9 @@ export const useDispatchJobStore = create()( sessionId: record.sessionId, targetRequest: requestFromTarget(record.target), target: record.target, - sourceWorkspacePath: fallbackSourceWorkspacePath, + sourceWorkspacePath: + record.sourceWorkspacePath || fallbackSourceWorkspacePath, + sourceWorkspaceId: record.sourceWorkspaceId, title: record.title || record.promptPreview || record.sessionId.slice(0, 8), agentType: record.agentType || 'agentic', approvalPolicy: record.approvalPolicy || 'reject-and-report', diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index 0bea7c94f8..77f710dcf0 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -227,6 +227,9 @@ export interface OutboundDispatchRecord { jobId: string; target: DispatchTarget; sessionId: string; + /** Controller workspace that owns the observer session. */ + sourceWorkspacePath?: string; + sourceWorkspaceId?: string; workspacePath: string; promptPreview: string; title?: string; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 4565dd205e..4c62d28944 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -422,7 +422,13 @@ describe('MessageModule detached dispatch', () => { titleStatus: 'generated', mode: 'agentic', dialogTurns: [] as any[], + workspacePath: '/controller/repo', + projectWorkspacePath: '/controller/repo', + workspaceId: 'workspace-1', config: { + workspacePath: '/controller/repo', + projectWorkspacePath: '/controller/repo', + workspaceId: 'workspace-1', modelName: 'controller-model', dispatchTargetRequest: { kind: 'ssh', @@ -533,6 +539,8 @@ describe('MessageModule detached dispatch', () => { prompt: 'expanded remote prompt', approvalPolicy: 'reject-and-report', model: undefined, + sourceWorkspacePath: '/controller/repo', + sourceWorkspaceId: 'workspace-1', }); expect(mockStartDialogTurn).not.toHaveBeenCalled(); expect(mockBindSession).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 7a05497ef0..0f4f0672de 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -412,6 +412,16 @@ export async function sendMessage( const workspaceDelivery = readySession.config.dispatchWorkspaceDelivery ?? { kind: 'existing' as const }; + const sourceWorkspacePath = + sessionProjectWorkspacePath(readySession) + || ( + workspaceDelivery.kind === 'snapshot-source' + || workspaceDelivery.kind === 'snapshot-exact' + ? workspaceDelivery.sourceWorkspacePath + : undefined + ); + const sourceWorkspaceId = + readySession.workspaceId || readySession.config.workspaceId; const transferRoundId = `dispatch-transfer:${jobId}`; showRuntimeStatus({ sessionId, @@ -434,6 +444,8 @@ export async function sendMessage( prompt: message, approvalPolicy, model: readySession.config.dispatchModel?.trim() || undefined, + ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}), + ...(sourceWorkspaceId ? { sourceWorkspaceId } : {}), }); } finally { clearRuntimeStatusState({ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 765f8d9372..e2a436f17d 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2250,6 +2250,8 @@ export class FlowChatStore { defaultModel?: string; state?: NonNullable; cursor?: number; + sourceWorkspacePath?: string; + sourceWorkspaceId?: string; }, ): void { this.setState(prev => { @@ -2271,10 +2273,21 @@ export class FlowChatStore { } const newSessions = new Map(prev.sessions); + const sourceWorkspacePath = binding.sourceWorkspacePath?.trim() || undefined; + const sourceWorkspaceId = binding.sourceWorkspaceId?.trim() || undefined; newSessions.set(sessionId, { ...session, + workspacePath: sourceWorkspacePath ?? session.workspacePath, + projectWorkspacePath: + sourceWorkspacePath ?? session.projectWorkspacePath, + workspaceId: sourceWorkspaceId ?? session.workspaceId, config: { ...session.config, + workspacePath: + sourceWorkspacePath ?? session.config.workspacePath, + projectWorkspacePath: + sourceWorkspacePath ?? session.config.projectWorkspacePath, + workspaceId: sourceWorkspaceId ?? session.config.workspaceId, dispatchTargetRequest: binding.targetRequest, dispatchTarget: binding.target, dispatchJobId: binding.jobId, diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts index 1bea75ea0b..3d5d3c12bd 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -174,4 +174,20 @@ describe('sessionOrdering', () => { sessionBelongsToWorkspaceNavRow(session, '/projects/other') ).toBe(false); }); + + it('does not assign a workspace-less session to every navigation row', () => { + const session = { + workspacePath: undefined, + projectWorkspacePath: undefined, + remoteConnectionId: undefined, + remoteSshHost: undefined, + }; + + expect( + sessionBelongsToWorkspaceNavRow(session, '/assistants/default') + ).toBe(false); + expect( + sessionBelongsToWorkspaceNavRow(session, '/projects/BitFun') + ).toBe(false); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index 8d0b75be87..705b0b6f76 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -32,11 +32,16 @@ export function sessionBelongsToWorkspaceNavRow( remoteConnectionId?: string | null, remoteSshHost?: string | null ): boolean { - const sessionRoot = session.workspacePath || workspacePath; - const projectRoot = session.projectWorkspacePath; + const sessionRoot = session.workspacePath?.trim(); + const projectRoot = session.projectWorkspacePath?.trim(); const pathsMatch = - isSamePath(sessionRoot, workspacePath) || - normalizeRemoteWorkspacePath(sessionRoot) === normalizeRemoteWorkspacePath(workspacePath) || + Boolean( + sessionRoot && + ( + isSamePath(sessionRoot, workspacePath) || + normalizeRemoteWorkspacePath(sessionRoot) === normalizeRemoteWorkspacePath(workspacePath) + ) + ) || Boolean( projectRoot && ( From 6047894a402fb22b7db13f7bb39e64fa211c499a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 22:20:31 -0700 Subject: [PATCH 06/11] fix(dispatch): reject stale target runtimes --- docs/architecture/detached-task-dispatch.md | 14 + src/apps/cli/src/dispatch/mod.rs | 19 +- src/apps/desktop/src/api/dispatch_api.rs | 112 +++++- .../src/remote_ssh/dispatch_ssh.rs | 335 ++++++++++++++++-- .../dispatch/DispatchInstallDialog.test.tsx | 2 + src/web-ui/src/features/dispatch/README.md | 4 + .../features/dispatch/dispatchPreflight.ts | 1 + src/web-ui/src/locales/en-US/common.json | 4 +- src/web-ui/src/locales/zh-CN/common.json | 4 +- src/web-ui/src/locales/zh-TW/common.json | 4 +- 10 files changed, 444 insertions(+), 55 deletions(-) diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index aa72e03185..df55f789f7 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -149,6 +149,18 @@ Workspace upload uses the internal `workspace-begin`, `workspace-chunk`, and `workspace-commit` verbs. They are target data-plane operations and are not normal product or Peer Device Mode commands. +`dispatch_worker_cli_profile` is a required execution-safety capability. It +means every dispatch process selects `DeliveryProfile::Cli` before model/config +inspection can lazily initialize product-full tool state. Controllers must +check it both during target setup and immediately before submission; package +version equality is not evidence of this behavior. + +CLI installation smoke-tests the same capability before replacing an existing +target binary. An untagged Desktop development build may, after the normal +explicit source-build confirmation, archive its clean current Git commit and +build that exact source on the target. This avoids reinstalling an older +same-semver release while keeping executable transfer an explicit user action. + Account-device transport wraps target verbs in names reserved for detached dispatch, such as `dispatch_target_submit`. They are handled before the attach-shaped Peer Host bridge and never acquire an attached-controller lease. @@ -201,6 +213,8 @@ originally submitted the job. ## Failure rules - A missing or offline target fails submit; the Relay does not queue jobs. +- A target missing a required behavioral capability fails preflight before a + durable job is created, even when its CLI package version matches. - A lost submit response leaves `submission_unknown`; status or an idempotent retry reconciles the target's durable truth. - A live PID that no longer matches the exact worker command is never signaled diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index d5776de7bd..00fce19bb1 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -19,8 +19,8 @@ use protocol::{ DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceBeginRequest, DispatchWorkspaceChunkRequest, DispatchWorkspaceCommitRequest, - DispatchWorkspaceResultChunkRequest, DispatchWorkspaceResultRequest, - DispatchWorkspaceProbe, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, + DispatchWorkspaceProbe, DispatchWorkspaceResultChunkRequest, DispatchWorkspaceResultRequest, + DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, }; use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; @@ -76,10 +76,12 @@ pub(crate) async fn run_dispatch_verb( DispatchWorkspaceResultRequest, >(input)?)?) .context("encode workspace result response"), - "workspace-result-chunk" => serde_json::to_value(workspace::result_chunk(parse::< - DispatchWorkspaceResultChunkRequest, - >(input)?)?) - .context("encode workspace result chunk response"), + "workspace-result-chunk" => { + serde_json::to_value(workspace::result_chunk(parse::< + DispatchWorkspaceResultChunkRequest, + >(input)?)?) + .context("encode workspace result chunk response") + } _ => bail!("unsupported dispatch verb: {verb}"), } } @@ -111,6 +113,11 @@ async fn probe(request: DispatchProbeRequest) -> Result { "event_log_completeness".to_string(), "workspace_snapshot_exact".to_string(), "workspace_snapshot_chunked".to_string(), + // A target may share the same package version while predating the + // dispatch entrypoint's early CLI-profile selection. Such a binary can + // accept a job but every detached worker then fails before execution. + // Advertise the behavioral fix explicitly so controllers fail closed. + "dispatch_worker_cli_profile".to_string(), // Optional on purpose: controllers must feature-detect this rather than // require it, so an older target stays usable for everything else. "workspace_result_bundle".to_string(), diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 0f4331122c..4efd9242ee 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -4,29 +4,31 @@ //! thin host adapters around the platform-neutral dispatch controller and its //! observer-only outbound index. -use std::sync::Arc; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use async_trait::async_trait; use bitfun_core::infrastructure::PathManager; use bitfun_core::service::dispatch::{ answer_device_dispatch, answer_dispatch, append_device_dispatch, append_dispatch, - apply_dispatch_result, DispatchApplyResultRequest, WorkspaceResultApplyOutcome, - cancel_device_dispatch, cancel_dispatch, cancel_dispatch_cli_install, + apply_dispatch_result, cancel_device_dispatch, cancel_dispatch, cancel_dispatch_cli_install, get_device_dispatch_status, get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target, probe_dispatch_target, pull_device_dispatch_result, pull_dispatch_result, - start_dispatch_cli_install, - start_dispatch_cli_source_build, submit_device_dispatch, submit_dispatch, - sync_dispatch_model_config, - DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, + start_dispatch_cli_install, start_dispatch_cli_source_build, submit_device_dispatch, + submit_dispatch, sync_dispatch_model_config, DeviceDispatchRpc, DispatchAnswerRequest, + DispatchAppendRequest, DispatchApplyResultRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, DispatchTargetOption, - DispatchTargetRequest, OutboundDispatchStore, + DispatchTargetRequest, OutboundDispatchStore, WorkspaceResultApplyOutcome, }; use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, }; +use bitfun_services_integrations::remote_ssh::dispatch_ssh::install_cli_source_archive_start; use serde_json::Value; use tauri::State; @@ -34,6 +36,77 @@ use super::app_state::AppState; struct AccountDeviceDispatchRpc; +const MAX_CONTROLLER_SOURCE_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; + +#[cfg(debug_assertions)] +fn controller_source_root() -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3)? + .to_path_buf(); + (root.join("Cargo.toml").is_file() && root.join(".git").exists()).then_some(root) +} + +#[cfg(not(debug_assertions))] +fn controller_source_root() -> Option { + None +} + +async fn archive_controller_source(root: PathBuf) -> anyhow::Result<(Vec, String)> { + tokio::task::spawn_blocking(move || { + let status = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&root) + .output() + .map_err(|error| anyhow::anyhow!("inspect controller source checkout: {error}"))?; + if !status.status.success() { + anyhow::bail!( + "inspect controller source checkout: {}", + String::from_utf8_lossy(&status.stderr).trim() + ); + } + if !status.stdout.is_empty() { + anyhow::bail!( + "the controller source checkout has uncommitted changes; commit them and restart Desktop before updating the target CLI" + ); + } + + let revision = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&root) + .output() + .map_err(|error| anyhow::anyhow!("resolve controller source revision: {error}"))?; + if !revision.status.success() { + anyhow::bail!( + "resolve controller source revision: {}", + String::from_utf8_lossy(&revision.stderr).trim() + ); + } + let revision = String::from_utf8(revision.stdout) + .map_err(|error| anyhow::anyhow!("controller source revision is not UTF-8: {error}"))? + .trim() + .to_string(); + + let archive = std::process::Command::new("git") + .args(["archive", "--format=tar.gz", "HEAD"]) + .current_dir(&root) + .output() + .map_err(|error| anyhow::anyhow!("archive controller source: {error}"))?; + if !archive.status.success() { + anyhow::bail!( + "archive controller source: {}", + String::from_utf8_lossy(&archive.stderr).trim() + ); + } + if archive.stdout.is_empty() || archive.stdout.len() > MAX_CONTROLLER_SOURCE_ARCHIVE_BYTES { + anyhow::bail!("controller source archive is empty or exceeds the 512 MB limit"); + } + Ok((archive.stdout, revision)) + }) + .await + .map_err(|error| anyhow::anyhow!("controller source archive task failed: {error}"))? +} + #[async_trait] impl DeviceDispatchRpc for AccountDeviceDispatchRpc { async fn invoke(&self, device_id: &str, command: &str, args: Value) -> anyhow::Result { @@ -129,9 +202,15 @@ pub async fn dispatch_probe_target( .get_ssh_manager_async() .await .map_err(|error| error.to_string())?; - probe_dispatch_target(&manager, request) + let mut probe = probe_dispatch_target(&manager, request) .await - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + if controller_source_root().is_some() { + if let Some(source_build) = probe.source_build.as_mut() { + source_build.git_ref = "current-controller-checkout".to_string(); + } + } + Ok(probe) } #[tauri::command] @@ -159,6 +238,19 @@ pub async fn dispatch_install_cli_source_start( .get_ssh_manager_async() .await .map_err(|error| error.to_string())?; + if let Some(root) = controller_source_root() { + let (archive, revision) = archive_controller_source(root) + .await + .map_err(|error| error.to_string())?; + return install_cli_source_archive_start( + &manager, + request.connection_id.trim(), + &archive, + &revision, + ) + .await + .map_err(|error| error.to_string()); + } start_dispatch_cli_source_build(&manager, request) .await .map_err(|error| error.to_string()) diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index b9db59cda8..c38ce1a273 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -50,7 +50,8 @@ const GLIBC_FLOOR: &str = "2.35"; const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; const DISPATCH_PROTOCOL_VERSION: u64 = 2; -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 12] = [ +const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profile"; +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 13] = [ "persistent_jobs", "cursor_events", "detached_worker", @@ -63,6 +64,7 @@ const REQUIRED_DISPATCH_CAPABILITIES: [&str; 12] = [ "event_log_completeness", "workspace_snapshot_exact", "workspace_snapshot_chunked", + DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, ]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -211,7 +213,12 @@ pub async fn probe( ) .await { - Ok(response) => protocol = Some(response), + Ok(response) => { + protocol_error = validate_dispatch_protocol(&response, None) + .err() + .map(|error| error.to_string()); + protocol = Some(response); + } Err(error) => protocol_error = Some(error.to_string()), } } @@ -222,7 +229,9 @@ pub async fn probe( .is_some_and(dispatch_protocol_is_compatible); // A platform mismatch is decided before any network work: no release exists // that would install successfully, so resolving one only hides the reason. - let incompatibility = needs_install.then(|| prebuilt_incompatibility(&target)).flatten(); + let incompatibility = needs_install + .then(|| prebuilt_incompatibility(&target)) + .flatten(); let (release, install_error) = if needs_install { if let Some(incompatibility) = &incompatibility { (None, Some(incompatibility.describe())) @@ -248,7 +257,7 @@ pub async fn probe( ( None, Some(format!( - "target already runs BitFun CLI {version}, which did not answer the dispatch protocol ({detail}); reinstalling the same release cannot change this" + "target already runs BitFun CLI {version}, which is incompatible with this controller ({detail}); reinstalling the same release cannot change this" )), ) } @@ -279,7 +288,9 @@ pub async fn probe( protocol_error, release, protocol, - prebuilt_incompatible: incompatibility.as_ref().map(PrebuiltIncompatibility::describe), + prebuilt_incompatible: incompatibility + .as_ref() + .map(PrebuiltIncompatibility::describe), source_build, }) } @@ -386,7 +397,9 @@ fn source_build_availability(target: &RemoteTarget) -> DispatchSourceBuild { blockers.push("no git on the target".to_string()); } if !target.cc_available { - blockers.push("no C compiler on the target (install build-essential or equivalent)".to_string()); + blockers.push( + "no C compiler on the target (install build-essential or equivalent)".to_string(), + ); } if let Some(free_kb) = target.free_kb { if free_kb < SOURCE_BUILD_FREE_KB { @@ -443,6 +456,7 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str "workspace_serialization", "frontend_event_projection", "approval_auto", + DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, ], Some("reject-and-report") => &[ "persistent_jobs", @@ -451,6 +465,7 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str "workspace_serialization", "frontend_event_projection", "approval_reject_and_report", + DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, ], Some("remote") => &[ "persistent_jobs", @@ -459,6 +474,7 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str "workspace_serialization", "frontend_event_projection", "approval_remote", + DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, ], Some(_) => return Err(anyhow!("unsupported dispatch approval policy")), None => &REQUIRED_DISPATCH_CAPABILITIES, @@ -862,6 +878,117 @@ pub async fn install_cli_source_start( }) } +/// Build and install the CLI from an exact controller-provided source archive. +/// +/// Development Desktop builds use this after the same explicit source-build +/// confirmation as the repository-clone path. It prevents an untagged +/// controller from "updating" a same-semver target back to an older release +/// whose dispatch protocol is missing required behavioral capabilities. +pub async fn install_cli_source_archive_start( + manager: &SSHConnectionManager, + connection_id: &str, + source_archive: &[u8], + revision: &str, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + if source_archive.is_empty() { + return Err(anyhow!("controller source archive is empty")); + } + if source_archive.len() > MAX_ARCHIVE_BYTES { + return Err(anyhow!( + "controller source archive exceeds the {} MB safety limit", + MAX_ARCHIVE_BYTES / (1024 * 1024) + )); + } + let revision = revision.trim(); + if revision.is_empty() + || revision.len() > 80 + || !revision.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + { + return Err(anyhow!("controller source revision is invalid")); + } + + let target = probe_remote_target(manager, connection_id).await?; + let mut availability = source_build_availability(&target); + // The controller supplied a complete archive, so the target does not need + // git. It still needs tar to unpack that archive. + availability + .blockers + .retain(|blocker| blocker != "no git on the target"); + if !target.tar_available { + availability + .blockers + .push("no tar executable on the target".to_string()); + } + if !availability.blockers.is_empty() { + return Err(anyhow!( + "target cannot build BitFun from controller source: {}", + availability.blockers.join("; ") + )); + } + + install_cli_cancel(manager, connection_id) + .await + .context("stop an earlier BitFun CLI installation")?; + + let dir = format!("{}/{}", target.home, INSTALL_STATE_DIR); + let archive_path = format!("{dir}/controller-source.tar.gz"); + let body_path = format!("{dir}/{INSTALL_STEM}-body.sh"); + let script_path = format!("{dir}/{INSTALL_STEM}.sh"); + let install_token = format!("bitfun-install-{}", uuid::Uuid::new_v4().as_simple()); + let version = RELEASE_VERSION + .split('+') + .next() + .unwrap_or(RELEASE_VERSION) + .to_string(); + + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {dir} && chmod 700 {root} {dispatch} {dir}", + root = shell_quote_posix(&format!("{}/.bitfun", target.home)), + dispatch = shell_quote_posix(&format!("{}/.bitfun/dispatch", target.home)), + dir = shell_quote_posix(&dir), + ), + ) + .await?; + manager + .sftp_write(connection_id, &archive_path, source_archive) + .await + .context("stage controller BitFun source archive")?; + + let body = to_unix_script(&source_archive_build_body_script( + &dir, + &archive_path, + &version, + revision, + )); + let driver = to_unix_script(&install_driver_script(&dir, &body_path, &install_token)); + stage_and_launch_installer( + manager, + connection_id, + &dir, + Some(&archive_path), + &body_path, + &script_path, + &body, + &driver, + &install_token, + ) + .await?; + + Ok(DispatchInstallStart { + script_path, + version, + target: format!("{} {}", target.os, target.arch), + url: format!("controller-source:{revision}"), + sha256: String::new(), + }) +} + fn ensure_confirmed_release( resolved: &DispatchCliRelease, expected: &DispatchCliRelease, @@ -1010,7 +1137,9 @@ pub async fn sync_model_config( } let config_dir = get("dir"); if config_dir.is_empty() { - return Err(anyhow!("could not resolve the target BitFun config directory")); + return Err(anyhow!( + "could not resolve the target BitFun config directory" + )); } let config_path = format!("{config_dir}/app.json"); @@ -1067,7 +1196,8 @@ fn validate_model_config_payload( } if payload .get("models") - .and_then(Value::as_array).is_none_or(|models| models.is_empty()) + .and_then(Value::as_array) + .is_none_or(|models| models.is_empty()) { return Err(anyhow!( "the controller has no configured AI models to sync" @@ -2005,6 +2135,14 @@ if ! staged_dispatch="$("$PRIMARY_NEW" dispatch --help 2>&1 >/dev/null)"; then echo "ERROR: this BitFun build does not provide dispatch support: $staged_dispatch" >&2 exit 1 fi +if ! staged_probe="$(printf '{{}}\n' | "$PRIMARY_NEW" dispatch probe 2>/dev/null)"; then + echo "ERROR: staged BitFun dispatch probe failed" >&2 + exit 1 +fi +case "$staged_probe" in + *'"{worker_profile_capability}"'*) ;; + *) echo "ERROR: staged BitFun CLI lacks safe dispatch worker profile selection" >&2; exit 1 ;; +esac if [ -e "$PRIMARY_TARGET" ]; then mv -f "$PRIMARY_TARGET" "$PRIMARY_BACKUP" HAD_PRIMARY=1 @@ -2030,7 +2168,8 @@ COMMITTED=1 {post_commit} echo "Installed $installed at $HOME/.local/bin/bitfun" echo {INSTALL_DONE_MARKER} -"# +"#, + worker_profile_capability = DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, ) } @@ -2118,6 +2257,43 @@ LEGACY="$SRC/target/release/bitfun-cli" ) } +fn source_archive_build_body_script( + dir: &str, + archive_path: &str, + expected_version: &str, + revision: &str, +) -> String { + let build = format!( + r#"SRC="$D/source" +SOURCE_ARCHIVE={archive} +echo "Building BitFun CLI controller source {revision_plain} on the target. This can take a while." +FREE_KB="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" +if [ "${{FREE_KB:-0}}" -lt {free_kb} ]; then + echo "ERROR: source build needs about {free_gb} GB free under $HOME, found $((FREE_KB / 1024 / 1024)) GB" >&2 + exit 1 +fi +rm -rf "$SRC" +mkdir -p "$SRC" +tar -xzf "$SOURCE_ARCHIVE" -C "$SRC" +echo ">>> cargo build --release (bitfun, bitfun-cli)" +( cd "$SRC" && cargo build --release --locked -p bitfun-cli --bin bitfun --bin bitfun-cli ) +PRIMARY="$SRC/target/release/bitfun" +LEGACY="$SRC/target/release/bitfun-cli" +[ -f "$PRIMARY" ] || {{ echo "ERROR: source build produced no bitfun binary" >&2; exit 1; }} +[ -f "$LEGACY" ] || {{ echo "ERROR: source build produced no bitfun-cli binary" >&2; exit 1; }} +"#, + archive = shell_quote_posix(archive_path), + revision_plain = revision, + free_kb = SOURCE_BUILD_FREE_KB, + free_gb = SOURCE_BUILD_FREE_KB / 1024 / 1024, + ); + format!( + "{preamble}{build}{commit}", + preamble = install_preamble_fragment(dir, expected_version), + commit = install_commit_fragment(r#"rm -rf "$SRC"; rm -f "$SOURCE_ARCHIVE""#), + ) +} + fn install_driver_script(dir: &str, body_path: &str, install_token: &str) -> String { format!( r#"#!/bin/bash @@ -2472,11 +2648,8 @@ mod tests { "/home/user/.bitfun/dispatch/install/install-cli-body.sh", "bitfun-install-test-token", ); - let source = source_build_body_script( - "/home/user/.bitfun/dispatch/install", - "1.2.3", - "v1.2.3", - ); + let source = + source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"); for (name, script) in [("body", body), ("driver", driver), ("source", source)] { let script = to_unix_script(&script); assert!(!script.contains('\r'), "{name} must be LF-only"); @@ -2569,11 +2742,14 @@ mod tests { let staging = temp.path().join(".results"); std::fs::create_dir_all(&staging).expect("staging"); // Simulate a permissive umask having created it. - std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o755)) - .expect("loosen"); + std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o755)).expect("loosen"); harden_result_directory(&staging).expect("harden"); assert_eq!( - std::fs::metadata(&staging).expect("stat").permissions().mode() & 0o777, + std::fs::metadata(&staging) + .expect("stat") + .permissions() + .mode() + & 0o777, 0o700, "the staging directory holds user source and must not be world-readable" ); @@ -2581,7 +2757,11 @@ mod tests { let bundle = staging.join("job-1.tar.gz"); write_private_file(&bundle, b"bundle bytes").expect("write"); assert_eq!( - std::fs::metadata(&bundle).expect("stat").permissions().mode() & 0o777, + std::fs::metadata(&bundle) + .expect("stat") + .permissions() + .mode() + & 0o777, 0o600, "the bundle itself must be owner-only" ); @@ -2590,7 +2770,11 @@ mod tests { // Rewriting must not widen the mode or leave a stale tail. write_private_file(&bundle, b"short").expect("rewrite"); assert_eq!( - std::fs::metadata(&bundle).expect("stat").permissions().mode() & 0o777, + std::fs::metadata(&bundle) + .expect("stat") + .permissions() + .mode() + & 0o777, 0o600 ); assert_eq!(std::fs::read(&bundle).expect("read"), b"short"); @@ -2690,7 +2874,10 @@ mod tests { availability.blockers ); assert!( - availability.blockers.iter().any(|b| b.contains("rustup.rs")), + availability + .blockers + .iter() + .any(|b| b.contains("rustup.rs")), "a missing toolchain must say where to get one" ); @@ -2711,7 +2898,14 @@ mod tests { "1.2.3", &ArchiveSource::TargetDownload, ); - let source = source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"); + let source = + source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"); + let controller_source = source_archive_build_body_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/controller-source.tar.gz", + "1.2.3", + "abc123", + ); // The atomic-replace and rollback semantics must not be able to drift // between the two paths. let commit = install_commit_fragment(r#"rm -f "$ARCHIVE""#); @@ -2719,7 +2913,11 @@ mod tests { .lines() .find(|line| line.contains("mv -f \"$PRIMARY_NEW\"")) .expect("commit fragment swaps the primary"); - for (name, script) in [("release", &release), ("source", &source)] { + for (name, script) in [ + ("release", &release), + ("source", &source), + ("controller source", &controller_source), + ] { assert!(script.contains(shared), "{name} must use the shared commit"); assert!( script.contains(r#"PRIMARY_NEW="$STAGE/bitfun""#), @@ -2729,6 +2927,10 @@ mod tests { script.contains("rollback_install"), "{name} must keep rollback" ); + assert!( + script.contains(DISPATCH_WORKER_CLI_PROFILE_CAPABILITY), + "{name} must reject a CLI whose detached worker can select the wrong profile" + ); } assert!( source.contains("cargo build --release --locked"), @@ -2742,6 +2944,12 @@ mod tests { source.contains(r#"rm -rf "$SRC""#), "the checkout must be cleaned up after a successful build" ); + assert!( + controller_source.contains("tar -xzf \"$SOURCE_ARCHIVE\"") + && controller_source.contains("controller source abc123") + && !controller_source.contains("git clone"), + "a controller archive must build exactly the confirmed local revision" + ); } #[test] @@ -2797,8 +3005,11 @@ mod tests { fn a_target_missing_its_tools_falls_back_to_the_push_path() { let signed = test_release(true); assert!( - target_download_blocker(&test_target(None, Some(RemoteDigestTool::Sha256Sum)), &signed) - .is_some(), + target_download_blocker( + &test_target(None, Some(RemoteDigestTool::Sha256Sum)), + &signed + ) + .is_some(), "no curl or wget must fall back" ); assert!( @@ -2823,8 +3034,7 @@ mod tests { ("shasum -a 256", RemoteDigestTool::Shasum), ] .into_iter() - .find(|(command, _)| available(command.split_whitespace().next().unwrap_or(command))) - else { + .find(|(command, _)| available(command.split_whitespace().next().unwrap_or(command))) else { return; // no digest tool on this host }; if !available("curl") { @@ -2839,7 +3049,10 @@ mod tests { let digest_output = std::process::Command::new("bash") .args([ "-c", - &format!("{digest_command} {}", shell_quote_posix(&source.to_string_lossy())), + &format!( + "{digest_command} {}", + shell_quote_posix(&source.to_string_lossy()) + ), ]) .output() .expect("compute digest"); @@ -2924,7 +3137,20 @@ mod tests { /// Mirrors a real `bitfun`: answers `--version` and has a `dispatch` /// subcommand. const DISPATCH_CAPABLE_PRIMARY: &str = "#!/bin/bash\n\ - if [ \"${1:-}\" = dispatch ]; then exit 0; fi\n\ + if [ \"${1:-}\" = dispatch ]; then\n\ + if [ \"${2:-}\" = probe ]; then\n\ + echo '{\"capabilities\":[\"dispatch_worker_cli_profile\"]}'\n\ + fi\n\ + exit 0\n\ + fi\n\ + echo \"bitfun 1.2.3\"\n"; + + /// Has the dispatch command but predates safe worker profile selection. + const UNSAFE_DISPATCH_PRIMARY: &str = "#!/bin/bash\n\ + if [ \"${1:-}\" = dispatch ]; then\n\ + if [ \"${2:-}\" = probe ]; then echo '{\"capabilities\":[]}' ; fi\n\ + exit 0\n\ + fi\n\ echo \"bitfun 1.2.3\"\n"; /// Mirrors a release that predates dispatch: the binary is healthy and @@ -2974,9 +3200,24 @@ mod tests { } #[cfg(unix)] - fn run_install_body_fixture( - primary: &str, - ) -> (std::process::Output, tempfile::TempDir) { + #[test] + fn a_dispatch_build_without_safe_worker_profile_selection_is_not_installed() { + let (output, temp) = run_install_body_fixture(UNSAFE_DISPATCH_PRIMARY); + assert!( + !output.status.success(), + "a target that accepts jobs but cannot run workers must fail installation" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("lacks safe dispatch worker profile selection"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!temp.path().join(".local/bin/bitfun").exists()); + } + + #[cfg(unix)] + fn run_install_body_fixture(primary: &str) -> (std::process::Output, tempfile::TempDir) { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); @@ -2986,7 +3227,8 @@ mod tests { let pkg = temp.path().join("pkg/bitfun-cli-1.2.3-test"); std::fs::create_dir_all(&pkg).expect("package dir"); std::fs::write(pkg.join("bitfun"), primary).expect("write primary"); - std::fs::write(pkg.join("bitfun-cli"), SIBLING_RESOLVING_COMPANION).expect("write companion"); + std::fs::write(pkg.join("bitfun-cli"), SIBLING_RESOLVING_COMPANION) + .expect("write companion"); for name in ["bitfun", "bitfun-cli"] { std::fs::set_permissions(pkg.join(name), std::fs::Permissions::from_mode(0o755)) .expect("chmod package binary"); @@ -3132,6 +3374,12 @@ mod tests { "bitfun-install-test-token", ), source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"), + source_archive_build_body_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/controller-source.tar.gz", + "1.2.3", + "abc123", + ), target_download_script( RemoteDownloader::Curl, RemoteDigestTool::Sha256Sum, @@ -3481,11 +3729,32 @@ mod tests { "detached_worker", "workspace_serialization", "frontend_event_projection", - "approval_reject_and_report" + "approval_reject_and_report", + DISPATCH_WORKER_CLI_PROFILE_CAPABILITY ], }); validate_dispatch_protocol(&reject_only, Some("reject-and-report")) .expect("selected policy is supported"); assert!(validate_dispatch_protocol(&reject_only, Some("auto")).is_err()); + + let unsafe_worker = serde_json::json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "capabilities": [ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "approval_reject_and_report" + ], + }); + let error = validate_dispatch_protocol(&unsafe_worker, Some("reject-and-report")) + .expect_err("a worker that can select product-full first must be rejected"); + assert!( + error + .to_string() + .contains(DISPATCH_WORKER_CLI_PROFILE_CAPABILITY), + "{error}" + ); } } diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 09cd4a8e48..97ef528488 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -324,6 +324,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { 'detached_worker', 'frontend_event_projection', 'workspace_serialization', + 'dispatch_worker_cli_profile', 'workspace_snapshot_exact', 'workspace_snapshot_chunked', 'approval_remote', @@ -472,6 +473,7 @@ describe('DispatchInstallDialog model configuration sync', () => { 'detached_worker', 'frontend_event_projection', 'workspace_serialization', + 'dispatch_worker_cli_profile', ], modelConfigured, availableModels: modelConfigured ? ['claude'] : [], diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 5cfde3deaa..643c6a4533 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -63,3 +63,7 @@ dispatch. identity. Legacy records wait for a concrete workspace fallback before creating a projection; a workspace-less projection must never match every navigation group. +22. CLI compatibility is capability-based, not semver-only. A target must + advertise safe CLI-profile selection for detached workers; development + source updates use the clean controller commit only after the existing + explicit source-build confirmation. diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index 8c2129e844..1f71c579c9 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -10,6 +10,7 @@ export const BASE_DISPATCH_CAPABILITIES = [ 'detached_worker', 'frontend_event_projection', 'workspace_serialization', + 'dispatch_worker_cli_profile', ] as const; export function isDispatchWorkspaceReady( diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index ec9cc45221..5195a58452 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1467,10 +1467,10 @@ "snapshotResultLocation": "Where results stay", "snapshotResultLocationHint": "The job runs in ~/.bitfun/dispatch/workspaces//current/ on the target. Results stay there and are never synced back automatically.", "sourceBuildTitle": "Build from source", - "sourceBuildDescription": "Clone and compile {{ref}} on the target. For platforms with no published binary; takes a while.", + "sourceBuildDescription": "Prepare and compile {{ref}} on the target. Used when the installed CLI is incompatible; takes a while.", "sourceBuildConfirm": "Build from source", "sourceBuildConfirmTitle": "Build the BitFun CLI from source on this target?", - "sourceBuildConfirmMessage": "The repository will be cloned on the target and built with cargo build --release. Needs about 6 GB free and can take tens of minutes.", + "sourceBuildConfirmMessage": "The selected source will be transferred or cloned on the target and built with cargo build --release. Needs about 6 GB free and can take tens of minutes.", "workspacePath": "Target workspace", "workspacePlaceholder": "/path/to/project", "check": "Check", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 42bd941ab2..61767870d2 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1467,10 +1467,10 @@ "snapshotResultLocation": "结果存放位置", "snapshotResultLocationHint": "任务在目标上的 ~/.bitfun/dispatch/workspaces/<任务ID>/current/ 中执行。结果保留在目标上,不会自动同步回本地。", "sourceBuildTitle": "从源码编译", - "sourceBuildDescription": "在目标上克隆并编译 {{ref}}。适用于没有可用预编译二进制的平台;耗时较长。", + "sourceBuildDescription": "在目标上准备并编译 {{ref}}。用于已安装 CLI 不兼容的情况;耗时较长。", "sourceBuildConfirm": "从源码编译", "sourceBuildConfirmTitle": "在此目标上从源码编译 BitFun CLI?", - "sourceBuildConfirmMessage": "将在目标上克隆仓库并执行 cargo build --release。需要约 6 GB 可用空间,可能耗时数十分钟。", + "sourceBuildConfirmMessage": "会把所选源码传输或克隆到目标上,并执行 cargo build --release。需要约 6 GB 可用空间,可能耗时数十分钟。", "workspacePath": "目标工作区", "workspacePlaceholder": "/项目/路径", "check": "检查", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 4cee48dde9..df3bbb16df 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1467,10 +1467,10 @@ "snapshotResultLocation": "結果存放位置", "snapshotResultLocationHint": "任務在目標上的 ~/.bitfun/dispatch/workspaces/<任務ID>/current/ 中執行。結果保留在目標上,不會自動同步回本機。", "sourceBuildTitle": "從原始碼編譯", - "sourceBuildDescription": "在目標上複製並編譯 {{ref}}。適用於沒有可用預編譯二進位檔的平台;耗時較長。", + "sourceBuildDescription": "在目標上準備並編譯 {{ref}}。用於已安裝 CLI 不相容的情況;耗時較長。", "sourceBuildConfirm": "從原始碼編譯", "sourceBuildConfirmTitle": "在此目標上從原始碼編譯 BitFun CLI?", - "sourceBuildConfirmMessage": "將在目標上複製儲存庫並執行 cargo build --release。需要約 6 GB 可用空間,可能耗時數十分鐘。", + "sourceBuildConfirmMessage": "會把所選原始碼傳輸或複製到目標上,並執行 cargo build --release。需要約 6 GB 可用空間,可能耗時數十分鐘。", "workspacePath": "目標工作區", "workspacePlaceholder": "/專案/路徑", "check": "檢查", From bc25c7fd8d082e4eca38f83b2c723c51d43ba4a3 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 22:28:01 -0700 Subject: [PATCH 07/11] fix(dispatch): judge CLI upgrades by capabilities --- .../src/remote_ssh/dispatch_ssh.rs | 138 ++++++++++-------- src/web-ui/src/locales/en-US/common.json | 2 +- src/web-ui/src/locales/zh-CN/common.json | 2 +- src/web-ui/src/locales/zh-TW/common.json | 2 +- 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index c38ce1a273..77e465d2a8 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -51,6 +51,13 @@ const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; const DISPATCH_PROTOCOL_VERSION: u64 = 2; const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profile"; +/// First stable release whose CLI is known to contain every capability below. +/// +/// Development builds can require capabilities before their next stable +/// version is published. In that window `CARGO_PKG_VERSION` still names the +/// previous release, so comparing only the installed and controller version +/// strings is not a sound compatibility test. +const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 15); const REQUIRED_DISPATCH_CAPABILITIES: [&str; 13] = [ "persistent_jobs", "cursor_events", @@ -129,8 +136,6 @@ struct RemoteTarget { arch: String, home: String, cli_path: Option, - /// Version string of the installed CLI, when one is present and runnable. - cli_version: Option, tar_available: bool, /// Fetcher the target can use to pull the release itself, if any. downloader: Option, @@ -240,29 +245,29 @@ pub async fn probe( None, Some("remote target has no tar executable; install tar and retry".to_string()), ) + } else if !published_release_supports_required_dispatch_protocol(RELEASE_VERSION) { + // The controller is ahead of the latest stable artifact. Avoid an + // unnecessary release request and offer its exact source instead. + (None, None) } else { match resolve_release(&target.os, &target.arch).await { - Ok(release) => match already_at_release_version(&target, &release) { - // Reinstalling a release the target already runs cannot add - // a protocol it does not implement. Offering the install - // anyway traps the user in a loop of successful installs - // that never clear the incompatibility. - // - // Carry the probe's own error: a release that genuinely - // predates dispatch and a target that failed to answer for - // some other reason look identical from here, and only the - // underlying message tells them apart. - Some(version) => { - let detail = protocol_error.as_deref().unwrap_or("no dispatch protocol"); - ( - None, - Some(format!( - "target already runs BitFun CLI {version}, which is incompatible with this controller ({detail}); reinstalling the same release cannot change this" - )), - ) - } - None => (Some(release.public), None), - }, + // Capability support is a fact about the published artifact, + // not about whether its semver happens to equal the installed + // binary. A locally or previously source-built CLI may share a + // version string with a different artifact. + Ok(release) + if published_release_supports_required_dispatch_protocol( + &release.public.version, + ) => + { + (Some(release.public), None) + } + // Before the first compatible stable release exists, the exact + // controller source is the only deterministic repair path. Do + // not show a speculative "same version means same binary" + // warning; the readiness row already names the missing + // capability and the source-build action explains the remedy. + Ok(_) => (None, None), Err(error) => (None, Some(error.to_string())), } } @@ -418,15 +423,34 @@ fn source_build_availability(target: &RemoteTarget) -> DispatchSourceBuild { } } -/// The version the target already runs, when it matches the release we would -/// install and therefore makes installing pointless. +/// Whether a published artifact is expected to implement the controller's +/// required protocol. /// -/// A CLI that answered `--version` with the exact release version is a working -/// binary, so the incompatibility is a missing feature in that release rather -/// than a damaged install. -fn already_at_release_version(target: &RemoteTarget, release: &ResolvedRelease) -> Option { - let installed = target.cli_version.as_deref()?; - (installed == release.public.version).then(|| installed.to_string()) +/// Nightly Desktop and CLI artifacts are built from the same checkout, so a +/// nightly is compatible by construction. Stable artifacts use an explicit +/// capability floor. This avoids treating equal version labels as proof that +/// two binaries are identical while still keeping known-old releases out of +/// the install loop. +fn published_release_supports_required_dispatch_protocol(version: &str) -> bool { + if version.contains("-nightly.") { + return true; + } + let core = version.split('+').next().unwrap_or(version); + let core = core.split('-').next().unwrap_or(core); + let mut parts = core.split('.'); + let parsed = ( + parts.next().and_then(|part| part.parse::().ok()), + parts.next().and_then(|part| part.parse::().ok()), + parts.next().and_then(|part| part.parse::().ok()), + ); + if parts.next().is_some() { + return false; + } + matches!( + parsed, + (Some(major), Some(minor), Some(patch)) + if (major, minor, patch) >= FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE + ) } fn dispatch_protocol_is_compatible(protocol: &Value) -> bool { @@ -515,6 +539,12 @@ pub async fn install_cli_start( )); } let release = resolve_release(&target.os, &target.arch).await?; + if !published_release_supports_required_dispatch_protocol(&release.public.version) { + return Err(anyhow!( + "published BitFun CLI {} does not contain the dispatch capabilities required by this controller; build from the controller source instead", + release.public.version + )); + } ensure_confirmed_release(&release.public, expected_release)?; // Stop an earlier attempt before replacing any of its staged files. A @@ -1801,18 +1831,11 @@ async fn probe_remote_target( return Err(anyhow!("could not resolve remote $HOME")); } let cli_path = get("cli"); - // `bitfun --version` prints "bitfun "; keep only the version. - let cli_version = get("cliversion") - .split_whitespace() - .next_back() - .unwrap_or_default() - .to_string(); Ok(RemoteTarget { os: get("os"), arch: get("arch"), home, cli_path: (!cli_path.is_empty()).then_some(cli_path), - cli_version: (!cli_version.is_empty()).then_some(cli_version), tar_available: get("tar") == "1", downloader: match get("downloader").as_str() { "curl" => Some(RemoteDownloader::Curl), @@ -1856,9 +1879,6 @@ else BITFUN_BIN="$(command -v bitfun 2>/dev/null || true)" fi printf 'cli=%s\n' "$BITFUN_BIN" -if [ -n "$BITFUN_BIN" ]; then - printf 'cliversion=%s\n' "$("$BITFUN_BIN" --version 2>/dev/null || true)" -fi if [ "$(uname -s 2>/dev/null || true)" = "Linux" ]; then if ls /lib/ld-musl-* >/dev/null 2>&1 || ldd --version 2>&1 | head -n1 | grep -qi musl; then printf 'libc=musl\n' @@ -2687,7 +2707,6 @@ mod tests { arch: "x86_64".to_string(), home: "/home/user".to_string(), cli_path: None, - cli_version: None, tar_available: true, downloader, digest_tool, @@ -2953,31 +2972,24 @@ mod tests { } #[test] - fn reinstalling_the_version_already_present_is_not_offered() { - let release = test_release(true); - let mut target = test_target( - Some(RemoteDownloader::Curl), - Some(RemoteDigestTool::Sha256Sum), - ); - - target.cli_version = Some("1.2.3".to_string()); - assert_eq!( - already_at_release_version(&target, &release).as_deref(), - Some("1.2.3"), - "an install that cannot change anything must not be offered" - ); - - target.cli_version = Some("1.2.2".to_string()); + fn release_compatibility_uses_capability_floor_not_installed_version() { assert!( - already_at_release_version(&target, &release).is_none(), - "an older target must still be offered the upgrade" + !published_release_supports_required_dispatch_protocol("0.2.14"), + "the last release without the worker profile must use the exact controller source" ); - - target.cli_version = None; assert!( - already_at_release_version(&target, &release).is_none(), - "a target with no runnable CLI must still be offered the install" + published_release_supports_required_dispatch_protocol("0.2.15"), + "the first compatible stable release must be installable" ); + assert!(published_release_supports_required_dispatch_protocol( + "0.3.0+build.1" + )); + assert!(published_release_supports_required_dispatch_protocol( + "0.2.14-nightly.20260730+abc123" + )); + assert!(!published_release_supports_required_dispatch_protocol( + "not-a-version" + )); } #[test] diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 5195a58452..6b6dac69bb 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1467,7 +1467,7 @@ "snapshotResultLocation": "Where results stay", "snapshotResultLocationHint": "The job runs in ~/.bitfun/dispatch/workspaces//current/ on the target. Results stay there and are never synced back automatically.", "sourceBuildTitle": "Build from source", - "sourceBuildDescription": "Prepare and compile {{ref}} on the target. Used when the installed CLI is incompatible; takes a while.", + "sourceBuildDescription": "Compile the controller-matched source ({{ref}}) on the target. Compatibility is verified by capabilities instead of guessed from the version label; this takes a while.", "sourceBuildConfirm": "Build from source", "sourceBuildConfirmTitle": "Build the BitFun CLI from source on this target?", "sourceBuildConfirmMessage": "The selected source will be transferred or cloned on the target and built with cargo build --release. Needs about 6 GB free and can take tens of minutes.", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 61767870d2..714f5548c6 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1467,7 +1467,7 @@ "snapshotResultLocation": "结果存放位置", "snapshotResultLocationHint": "任务在目标上的 ~/.bitfun/dispatch/workspaces/<任务ID>/current/ 中执行。结果保留在目标上,不会自动同步回本地。", "sourceBuildTitle": "从源码编译", - "sourceBuildDescription": "在目标上准备并编译 {{ref}}。用于已安装 CLI 不兼容的情况;耗时较长。", + "sourceBuildDescription": "在目标上编译与当前控制端一致的源码({{ref}})。兼容性按能力校验,不再仅凭版本号猜测;耗时较长。", "sourceBuildConfirm": "从源码编译", "sourceBuildConfirmTitle": "在此目标上从源码编译 BitFun CLI?", "sourceBuildConfirmMessage": "会把所选源码传输或克隆到目标上,并执行 cargo build --release。需要约 6 GB 可用空间,可能耗时数十分钟。", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index df3bbb16df..3179eee6d8 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1467,7 +1467,7 @@ "snapshotResultLocation": "結果存放位置", "snapshotResultLocationHint": "任務在目標上的 ~/.bitfun/dispatch/workspaces/<任務ID>/current/ 中執行。結果保留在目標上,不會自動同步回本機。", "sourceBuildTitle": "從原始碼編譯", - "sourceBuildDescription": "在目標上準備並編譯 {{ref}}。用於已安裝 CLI 不相容的情況;耗時較長。", + "sourceBuildDescription": "在目標上編譯與目前控制端一致的原始碼({{ref}})。相容性按能力驗證,不再僅憑版本號猜測;耗時較長。", "sourceBuildConfirm": "從原始碼編譯", "sourceBuildConfirmTitle": "在此目標上從原始碼編譯 BitFun CLI?", "sourceBuildConfirmMessage": "會把所選原始碼傳輸或複製到目標上,並執行 cargo build --release。需要約 6 GB 可用空間,可能耗時數十分鐘。", From 237755c444fb70f7383eba692d55df5886be26d8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 22:35:57 -0700 Subject: [PATCH 08/11] fix(dispatch): stop restoring unowned jobs --- .../dispatch/DispatchJobObserver.test.ts | 64 ++++++++++++++++++- .../features/dispatch/DispatchJobObserver.ts | 10 +-- src/web-ui/src/features/dispatch/README.md | 8 +-- .../dispatch/dispatchJobStore.test.ts | 42 +++++++++--- .../src/features/dispatch/dispatchJobStore.ts | 35 ++++++---- 5 files changed, 122 insertions(+), 37 deletions(-) diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 3088e3e33d..541d2cb920 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -416,7 +416,7 @@ describe('DispatchJobObserver', () => { cleanup(); }); - it('waits for workspace ownership before restoring a legacy projection', async () => { + it('never restores an unowned legacy job into the current workspace', async () => { const record = { jobId: 'job-restored', sessionId: 'session-restored', @@ -442,6 +442,10 @@ describe('DispatchJobObserver', () => { const context = createContext(); context.currentWorkspacePath = null; context.flowChatStore.getState = vi.fn(() => ({ sessions })); + context.flowChatStore.applyDispatchSnapshot = vi.fn(() => ({ + applied: true, + cursor: 0, + })); context.flowChatStore.addExternalSession = vi.fn(( sessionId: string, _title: string, @@ -463,6 +467,62 @@ describe('DispatchJobObserver', () => { context.currentWorkspacePath = '/projects/BitFun'; await vi.advanceTimersByTimeAsync(DISPATCH_JOB_POLL_INTERVAL_MS); + expect(context.flowChatStore.addExternalSession).not.toHaveBeenCalled(); + expect(dispatchJobStore.getState().jobs['job-restored']).toBeUndefined(); + expect(mocks.status).not.toHaveBeenCalled(); + cleanup(); + }); + + it('restores a projection only from its durable source workspace', async () => { + mocks.listJobs.mockResolvedValue([{ + jobId: 'job-restored', + sessionId: 'session-restored', + target: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + displayName: 'build-host', + }, + sourceWorkspacePath: '/projects/BitFun', + sourceWorkspaceId: 'workspace-1', + workspacePath: '/target/repo', + promptPreview: 'Dispatch test', + title: 'Dispatch test', + agentType: 'agentic', + approvalPolicy: 'reject-and-report', + lastCursor: 0, + lastState: 'running', + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }]); + mocks.status.mockResolvedValue(status()); + const sessions = new Map(); + const context = createContext(); + context.currentWorkspacePath = null; + context.flowChatStore.getState = vi.fn(() => ({ sessions })); + context.flowChatStore.applyDispatchSnapshot = vi.fn(() => ({ + applied: true, + cursor: 0, + })); + context.flowChatStore.addExternalSession = vi.fn(( + sessionId: string, + _title: string, + _mode: string, + workspacePath: string, + meta: { projectWorkspacePath: string; workspaceId?: string }, + ) => { + sessions.set(sessionId, { + sessionId, + workspacePath, + projectWorkspacePath: meta.projectWorkspacePath, + workspaceId: meta.workspaceId, + config: {}, + }); + }); + + const cleanup = installDispatchJobObserver(context); + await vi.advanceTimersByTimeAsync(0); + expect(context.flowChatStore.addExternalSession).toHaveBeenCalledWith( 'session-restored', 'Dispatch test', @@ -470,8 +530,10 @@ describe('DispatchJobObserver', () => { '/projects/BitFun', expect.objectContaining({ projectWorkspacePath: '/projects/BitFun', + workspaceId: 'workspace-1', }), ); + expect(mocks.status).toHaveBeenCalledWith('job-restored', 0); cleanup(); }); diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index b860993316..fb5ea57754 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -160,10 +160,7 @@ export function dispatchEventId(event: DispatchEvent): string { } function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): boolean { - const sourceWorkspacePath = - job.sourceWorkspacePath?.trim() - || context.currentWorkspacePath?.trim() - || undefined; + const sourceWorkspacePath = job.sourceWorkspacePath?.trim() || undefined; const existing = context.flowChatStore.getState().sessions.get(job.sessionId); if (existing) { // Reconcile both immutable target identity and controller-side ownership. @@ -529,10 +526,7 @@ export function installDispatchJobObserver(context: FlowChatContext): () => void inFlight = true; try { const records = await dispatchApi.listJobs(); - dispatchJobStore.getState().mergeOutboundRecords( - records, - context.currentWorkspacePath || undefined, - ); + dispatchJobStore.getState().mergeOutboundRecords(records); const jobs = Object.values(dispatchJobStore.getState().jobs) .filter(job => !requestedJobId || job.jobId === requestedJobId); for (const job of jobs) { diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 643c6a4533..c5f42319af 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -59,10 +59,10 @@ dispatch. 20. The controller projects the initial user turn before waiting for target startup. The target's `DialogTurnStarted` event adopts that pending turn in place so queued work is visible without duplicating the message. -21. Every outbound observer record carries its controller-side source workspace - identity. Legacy records wait for a concrete workspace fallback before - creating a projection; a workspace-less projection must never match every - navigation group. +21. Every projected outbound observer record carries its durable + controller-side source workspace identity. Legacy or adopted records + without that identity remain hidden; the renderer must never guess + ownership from whichever workspace initializes after restart. 22. CLI compatibility is capability-based, not semver-only. A target must advertise safe CLI-profile selection for detached workers; development source updates use the clean controller commit only after the existing diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts index 609dffe546..5f3e693565 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts @@ -20,6 +20,7 @@ function registerJob(state: 'running' | 'succeeded' = 'running'): void { workspacePath: '/repo', displayName: 'build-host', }, + sourceWorkspacePath: '/source', title: 'Dispatch test', agentType: 'agentic', approvalPolicy: 'reject-and-report', @@ -79,6 +80,7 @@ describe('dispatchJobStore', () => { workspacePath: '/repo', displayName: 'build-host', }, + sourceWorkspacePath: '/source', workspacePath: '/repo', promptPreview: 'Dispatch test', lastCursor: 9, @@ -104,6 +106,7 @@ describe('dispatchJobStore', () => { workspacePath: '/canonical/repo', displayName: 'build-host', }, + sourceWorkspacePath: '/source', workspacePath: '/canonical/repo', promptPreview: 'Dispatch test', lastCursor: 900, @@ -155,7 +158,7 @@ describe('dispatchJobStore', () => { }); }); - it('backfills a legacy outbound job after the source workspace initializes', () => { + it('drops a legacy outbound job instead of guessing its source workspace', () => { const record = { jobId: 'job-restored', sessionId: 'session-restored', @@ -173,18 +176,37 @@ describe('dispatchJobStore', () => { updatedAt: '2026-07-28T00:00:01Z', }; + // Simulate a cache polluted by the old current-workspace fallback. + dispatchJobStore.getState().registerJob({ + jobId: 'job-restored', + sessionId: 'session-restored', + targetRequest: { + kind: 'ssh', + connectionId: 'ssh-1', + workspacePath: '/target/repo', + }, + target: record.target, + sourceWorkspacePath: '/wrong/current/workspace', + title: 'Prompt preview', + agentType: 'agentic', + approvalPolicy: 'reject-and-report', + workspaceDelivery: { kind: 'existing' }, + cursor: 0, + state: 'running', + appliedEventIds: [], + pendingPermissions: [], + eventLogComplete: true, + historyTruncated: false, + omittedEventCount: 0, + createdAt: 1, + updatedAt: 1, + }); + dispatchJobStore.getState().mergeOutboundRecords([record]); + expect(dispatchJobStore.getState().jobs['job-restored']).toBeUndefined(); expect( - dispatchJobStore.getState().jobs['job-restored'].sourceWorkspacePath + dispatchJobStore.getState().transportByJobId['job-restored'], ).toBeUndefined(); - - dispatchJobStore.getState().mergeOutboundRecords( - [record], - '/projects/BitFun', - ); - expect( - dispatchJobStore.getState().jobs['job-restored'].sourceWorkspacePath - ).toBe('/projects/BitFun'); }); it('persists a dismissal tombstone so reconciliation cannot reopen the projection', () => { diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index c1532309ba..142f532224 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -70,10 +70,7 @@ interface DispatchJobStoreState { /** Local projection tombstones. The target job remains durable, but must not reopen in navigation. */ dismissedJobIds: string[]; registerJob: (job: DispatchObserverJob) => void; - mergeOutboundRecords: ( - records: OutboundDispatchRecord[], - fallbackSourceWorkspacePath?: string, - ) => void; + mergeOutboundRecords: (records: OutboundDispatchRecord[]) => void; updateProgress: ( jobId: string, update: { @@ -163,13 +160,25 @@ export const useDispatchJobStore = create()( }); }, - mergeOutboundRecords: (records, fallbackSourceWorkspacePath) => { + mergeOutboundRecords: (records) => { set(state => { const jobs = { ...state.jobs }; + const unownedJobIds = new Set(); for (const record of records) { if (state.dismissedJobIds.includes(record.jobId)) { continue; } + const sourceWorkspacePath = record.sourceWorkspacePath?.trim() || undefined; + if (!sourceWorkspacePath) { + // A legacy/adopted record without controller-side ownership + // cannot safely be projected into any workspace. In particular, + // never assign it to whichever workspace happened to initialize + // first after restart. Remove any previously inferred cache + // entry so the old behavior migrates itself away. + delete jobs[record.jobId]; + unownedJobIds.add(record.jobId); + continue; + } const existing = jobs[record.jobId]; if (existing) { const nextState = nextJobState(existing.state, record.lastState); @@ -178,13 +187,9 @@ export const useDispatchJobStore = create()( ...existing, target: record.target, targetRequest: requestFromTarget(record.target), - // The durable outbound record is authoritative when it knows - // the source. Legacy records are backfilled once FlowChat has - // initialized a concrete controller workspace. - sourceWorkspacePath: - record.sourceWorkspacePath - || existing.sourceWorkspacePath - || fallbackSourceWorkspacePath, + // The durable outbound record is the only authority allowed + // to restore a projection after renderer restart. + sourceWorkspacePath, sourceWorkspaceId: record.sourceWorkspaceId || existing.sourceWorkspaceId, title: record.title || existing.title, @@ -206,8 +211,7 @@ export const useDispatchJobStore = create()( sessionId: record.sessionId, targetRequest: requestFromTarget(record.target), target: record.target, - sourceWorkspacePath: - record.sourceWorkspacePath || fallbackSourceWorkspacePath, + sourceWorkspacePath, sourceWorkspaceId: record.sourceWorkspaceId, title: record.title || record.promptPreview || record.sessionId.slice(0, 8), agentType: record.agentType || 'agentic', @@ -229,6 +233,9 @@ export const useDispatchJobStore = create()( }; } const transportByJobId = { ...state.transportByJobId }; + for (const jobId of unownedJobIds) { + delete transportByJobId[jobId]; + } for (const jobId of Object.keys(jobs)) { transportByJobId[jobId] ??= { reachability: 'unknown' }; } From 38d09ec7a70239c2c0b9907f661980b43087e494 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 22:37:58 -0700 Subject: [PATCH 09/11] fix(dispatch): prune orphaned observer cache --- src/web-ui/src/features/dispatch/README.md | 4 +++- .../dispatch/dispatchJobStore.test.ts | 19 ++++++++++++++++++ .../src/features/dispatch/dispatchJobStore.ts | 20 ++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index c5f42319af..b2a6d4aab1 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -62,7 +62,9 @@ dispatch. 21. Every projected outbound observer record carries its durable controller-side source workspace identity. Legacy or adopted records without that identity remain hidden; the renderer must never guess - ownership from whichever workspace initializes after restart. + ownership from whichever workspace initializes after restart. After submit + acknowledgement, the controller index is authoritative and stale renderer + cache without a matching record is pruned. 22. CLI compatibility is capability-based, not semver-only. A target must advertise safe CLI-profile selection for detached workers; development source updates use the clean controller commit only after the existing diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts index 5f3e693565..2f2a291156 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts @@ -209,6 +209,25 @@ describe('dispatchJobStore', () => { ).toBeUndefined(); }); + it('drops acknowledged renderer cache missing from the controller index', () => { + registerJob(); + dispatchJobStore.getState().mergeOutboundRecords([]); + + expect(dispatchJobStore.getState().jobs['job-1']).toBeUndefined(); + expect(dispatchJobStore.getState().transportByJobId['job-1']).toBeUndefined(); + }); + + it('keeps a pre-ack job while the controller index has no record yet', () => { + registerJob(); + dispatchJobStore.getState().registerJob({ + ...dispatchJobStore.getState().jobs['job-1'], + state: 'submitting', + }); + dispatchJobStore.getState().mergeOutboundRecords([]); + + expect(dispatchJobStore.getState().jobs['job-1']?.state).toBe('submitting'); + }); + it('persists a dismissal tombstone so reconciliation cannot reopen the projection', () => { registerJob(); dispatchJobStore.getState().dismissJob('job-1'); diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index 142f532224..ce707412c9 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -163,7 +163,21 @@ export const useDispatchJobStore = create()( mergeOutboundRecords: (records) => { set(state => { const jobs = { ...state.jobs }; - const unownedJobIds = new Set(); + const authoritativeJobIds = new Set(records.map(record => record.jobId)); + const prunedJobIds = new Set(); + for (const [jobId, job] of Object.entries(jobs)) { + if ( + !authoritativeJobIds.has(jobId) + && job.state !== 'submitting' + && job.state !== 'submission_unknown' + ) { + // The controller index is authoritative after acknowledgement. + // Remove renderer cache left behind by retention, manual cleanup, + // or an older build instead of restoring a ghost projection. + delete jobs[jobId]; + prunedJobIds.add(jobId); + } + } for (const record of records) { if (state.dismissedJobIds.includes(record.jobId)) { continue; @@ -176,7 +190,7 @@ export const useDispatchJobStore = create()( // first after restart. Remove any previously inferred cache // entry so the old behavior migrates itself away. delete jobs[record.jobId]; - unownedJobIds.add(record.jobId); + prunedJobIds.add(record.jobId); continue; } const existing = jobs[record.jobId]; @@ -233,7 +247,7 @@ export const useDispatchJobStore = create()( }; } const transportByJobId = { ...state.transportByJobId }; - for (const jobId of unownedJobIds) { + for (const jobId of prunedJobIds) { delete transportByJobId[jobId]; } for (const jobId of Object.keys(jobs)) { From 19c09c4bffdf4001295ff68b84a7bf3ec2bb1540 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 22:59:21 -0700 Subject: [PATCH 10/11] perf(dispatch): reuse verified workspace snapshots --- docs/architecture/detached-task-dispatch.md | 8 + src/apps/cli/src/dispatch/mod.rs | 3 + src/apps/cli/src/dispatch/store.rs | 109 +++++++ src/apps/cli/src/dispatch/workspace.rs | 314 +++++++++++++++++++- 4 files changed, 426 insertions(+), 8 deletions(-) diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index df55f789f7..03ebefc52b 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -109,6 +109,14 @@ RPC uses bounded base64 chunks inside the existing end-to-end encrypted `HostInvoke` envelope. Neither transport puts source bytes in command-line arguments, process listings, logs, or the outbound observer record. +After a target has fully verified and materialized a snapshot, it retains one +owner-only archive keyed by the archive SHA-256. A later job with identical +metadata attaches that immutable archive and reports the full retained offset, +so both SSH and account-device controllers skip the source transfer. The +temporary per-job archive link is removed after materialization; each job still +gets its own writable `current/` directory, so cache reuse never makes jobs +share writes. Cache entries expire after 30 days without a hit. + ## Synchronization semantics A snapshot is an immutable input boundary, not a live shared folder: diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 00fce19bb1..cbce901d79 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -121,6 +121,9 @@ async fn probe(request: DispatchProbeRequest) -> Result { // Optional on purpose: controllers must feature-detect this rather than // require it, so an older target stays usable for everything else. "workspace_result_bundle".to_string(), + // Identical snapshots from different jobs reuse one verified archive + // on the target. Jobs still receive independent writable workspaces. + "workspace_snapshot_cache".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index a4728073f4..66208b71c5 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -39,6 +39,9 @@ const TERMINAL_JOB_RETENTION_DAYS: i64 = 30; const RETENTION_GC_INTERVAL_SECONDS: u64 = 24 * 60 * 60; const RETENTION_GC_MARKER: &str = ".retention-gc"; const RETENTION_GC_LOCK: &str = ".retention-gc.lock"; +const WORKSPACE_SNAPSHOT_CACHE_DIR: &str = "workspace-cache"; +pub(super) const WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE: &str = "cache.json"; +const WORKSPACE_SNAPSHOT_CACHE_RETENTION_DAYS: i64 = 30; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -128,6 +131,12 @@ struct StoredAppendMessage { created_at: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceSnapshotCacheRetentionRecord { + last_used_at: String, +} + #[derive(Clone, Debug)] pub(crate) struct DispatchStore { root: PathBuf, @@ -149,6 +158,7 @@ impl DispatchStore { create_private_dir(&root)?; create_private_dir(&root.join("jobs"))?; create_private_dir(&root.join("workspaces"))?; + create_private_dir(&root.join(WORKSPACE_SNAPSHOT_CACHE_DIR))?; Ok(Self { root, max_events_bytes: DEFAULT_MAX_EVENTS_BYTES, @@ -837,6 +847,10 @@ impl DispatchStore { Ok(self.root.join("workspaces").join(job_id)) } + pub(crate) fn workspace_snapshot_cache_root(&self) -> PathBuf { + self.root.join(WORKSPACE_SNAPSHOT_CACHE_DIR) + } + fn maybe_collect_expired_terminal_jobs(&self) -> Result<()> { let marker = self.root.join(RETENTION_GC_MARKER); if fs::metadata(&marker) @@ -1025,9 +1039,78 @@ impl DispatchStore { })?; removed += 1; } + self.collect_expired_workspace_snapshot_cache(now)?; Ok(removed) } + fn collect_expired_workspace_snapshot_cache( + &self, + now: chrono::DateTime, + ) -> Result<()> { + let cache_root = self.workspace_snapshot_cache_root(); + for entry in fs::read_dir(&cache_root) + .with_context(|| format!("read dispatch workspace cache {}", cache_root.display()))? + { + let entry = entry?; + let Some(digest) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + continue; + } + let cache_dir = entry.path(); + let metadata = fs::symlink_metadata(&cache_dir)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + continue; + } + let lock_path = cache_root.join(format!(".{digest}.lock")); + let Some(_lock) = JobLock::try_exclusive(&lock_path)? else { + continue; + }; + let record = match read_json::( + &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + ) { + Ok(record) => record, + Err(error) => { + tracing::warn!( + "Skipping unreadable dispatch workspace cache entry: digest={} error={error:#}", + digest + ); + continue; + } + }; + let Some(last_used_at) = chrono::DateTime::parse_from_rfc3339(&record.last_used_at) + .ok() + .map(|value| value.with_timezone(&chrono::Utc)) + else { + continue; + }; + if now.signed_duration_since(last_used_at).num_days() + < WORKSPACE_SNAPSHOT_CACHE_RETENTION_DAYS + { + continue; + } + let tombstone = cache_root.join(format!( + ".gc-{}-{}", + digest, + uuid::Uuid::new_v4().as_simple() + )); + fs::rename(&cache_dir, &tombstone).with_context(|| { + format!( + "quarantine expired dispatch workspace cache {}", + cache_dir.display() + ) + })?; + fs::remove_dir_all(&tombstone).with_context(|| { + format!( + "remove expired dispatch workspace cache {}", + tombstone.display() + ) + })?; + } + Ok(()) + } + fn load_state_unlocked(&self, job_dir: &Path) -> Result { read_json(&job_dir.join(STATE_FILE)) } @@ -2379,6 +2462,24 @@ mod tests { &expired, ) .expect("age terminal state"); + let expired_digest = "a".repeat(64); + let recent_digest = "b".repeat(64); + for (digest, last_used_at) in [ + ( + &expired_digest, + (now - chrono::Duration::days(WORKSPACE_SNAPSHOT_CACHE_RETENTION_DAYS + 1)) + .to_rfc3339(), + ), + (&recent_digest, now.to_rfc3339()), + ] { + let cache_dir = store.workspace_snapshot_cache_root().join(digest); + create_private_dir(&cache_dir).expect("create cache entry"); + atomic_write_json( + &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + &serde_json::json!({ "lastUsedAt": last_used_at }), + ) + .expect("write cache record"); + } assert_eq!( store @@ -2392,6 +2493,14 @@ mod tests { assert!(store.root.join("workspaces/recent").exists()); assert!(store.root.join("jobs/running").exists()); assert!(store.root.join("workspaces/running").exists()); + assert!(!store + .workspace_snapshot_cache_root() + .join(expired_digest) + .exists()); + assert!(store + .workspace_snapshot_cache_root() + .join(recent_digest) + .exists()); } #[test] diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index c234a61567..2afe626435 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -5,9 +5,10 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use base64::Engine as _; use bitfun_services_core::dispatch_workspace::{ - create_workspace_result_bundle, extract_workspace_snapshot, WorkspaceSnapshotManifest, - WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, MAX_SNAPSHOT_DIRECTORIES, - MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, WORKSPACE_SNAPSHOT_FORMAT_VERSION, + create_workspace_result_bundle, extract_workspace_snapshot, sha256_file, + WorkspaceSnapshotManifest, WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, + MAX_SNAPSHOT_DIRECTORIES, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, + WORKSPACE_SNAPSHOT_FORMAT_VERSION, }; use serde::{Deserialize, Serialize}; @@ -21,10 +22,12 @@ use super::protocol::{ use super::store::{ atomic_write_json, create_private_dir, read_json, remove_file_if_present, set_private_file_permissions, sync_directory, DispatchStore, JobLock, + WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE, }; const UPLOAD_RECORD_FILE: &str = "upload.json"; const UPLOAD_ARCHIVE_FILE: &str = "workspace.tar.gz"; +const CACHE_ARCHIVE_FILE: &str = "workspace.tar.gz"; const CURRENT_WORKSPACE_DIR: &str = "current"; /// The delivered snapshot's manifest, kept as the baseline a result diff is /// computed against. @@ -58,13 +61,28 @@ struct WorkspaceUploadRecord { last_error: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceSnapshotCacheRecord { + metadata: WorkspaceSnapshotMetadata, + created_at: String, + last_used_at: String, +} + pub(crate) fn begin( request: DispatchWorkspaceBeginRequest, ) -> Result { - validate_begin(&request)?; let store = DispatchStore::open_default()?; + begin_in_store(&store, request) +} + +fn begin_in_store( + store: &DispatchStore, + request: DispatchWorkspaceBeginRequest, +) -> Result { + validate_begin(&request)?; let upload_dir = store.workspace_upload_dir(&request.job_id)?; - let lock_path = workspace_upload_lock_path(&store, &request.job_id); + let lock_path = workspace_upload_lock_path(store, &request.job_id); let Some(_lock) = JobLock::try_exclusive(&lock_path)? else { let existing: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) .context("workspace upload is currently being initialized")?; @@ -148,6 +166,15 @@ pub(crate) fn begin( } let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + if try_attach_cached_snapshot(store, &upload_dir, &archive_path, &request.metadata)? { + return Ok(DispatchWorkspaceBeginResponse { + accepted: true, + offset: request.metadata.archive_size, + upload_path: archive_path.to_string_lossy().to_string(), + committed: false, + workspace_path: None, + }); + } let archive_metadata = fs::symlink_metadata(&archive_path); let offset = match archive_metadata { Ok(metadata) => { @@ -324,9 +351,8 @@ pub(crate) fn result( bail!("workspace snapshot is not committed yet"); } let baseline: WorkspaceSnapshotManifest = - read_json(&upload_dir.join(BASELINE_MANIFEST_FILE)).context( - "this job predates result bundles; its baseline manifest was not recorded", - )?; + read_json(&upload_dir.join(BASELINE_MANIFEST_FILE)) + .context("this job predates result bundles; its baseline manifest was not recorded")?; let current = upload_dir.join(CURRENT_WORKSPACE_DIR); if !is_real_directory(¤t) { @@ -423,6 +449,13 @@ fn materialize_in_store(store: &DispatchStore, job_id: &str) -> Result<()> { ) })?; sync_directory(&upload_dir)?; + if let Err(error) = persist_verified_snapshot_cache(store, &archive_path, &record.metadata) + { + tracing::warn!( + "Failed to retain verified dispatch workspace snapshot: digest={} error={error:#}", + record.metadata.archive_sha256 + ); + } mark_workspace_committed(&record_path, &upload_dir, &mut record)?; remove_file_if_present(&archive_path); Ok(()) @@ -531,6 +564,191 @@ fn validate_complete_archive( Ok(()) } +/// Reuse a verified snapshot uploaded by an earlier job. +/// +/// The cache owns one immutable, content-addressed archive. Each job gets a +/// hard link only while its detached materializer is reading the archive; the +/// link is removed after `current/` is published. This keeps writable job +/// workspaces isolated while avoiding another network transfer and another +/// compressed archive on the target. +fn try_attach_cached_snapshot( + store: &DispatchStore, + upload_dir: &Path, + archive_path: &Path, + expected: &WorkspaceSnapshotMetadata, +) -> Result { + let cache_root = store.workspace_snapshot_cache_root(); + let digest = expected.archive_sha256.to_ascii_lowercase(); + let cache_dir = cache_root.join(&digest); + let _lock = JobLock::exclusive(&workspace_snapshot_cache_lock_path(store, &digest))?; + let Some(mut record) = read_valid_snapshot_cache(&cache_dir, expected)? else { + discard_snapshot_cache_entry(&cache_dir)?; + return Ok(false); + }; + + match fs::symlink_metadata(archive_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("workspace upload archive is not a regular file"); + } + fs::remove_file(archive_path) + .with_context(|| format!("replace workspace upload {}", archive_path.display()))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("inspect workspace upload archive"), + } + fs::hard_link(cache_dir.join(CACHE_ARCHIVE_FILE), archive_path).with_context(|| { + format!( + "attach cached workspace snapshot {} -> {}", + cache_dir.display(), + upload_dir.display() + ) + })?; + set_private_file_permissions(archive_path)?; + + record.last_used_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json( + &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + &record, + )?; + Ok(true) +} + +/// Publish an archive only after extraction verified its archive digest, +/// manifest digest, paths, entry types, and size limits. +fn persist_verified_snapshot_cache( + store: &DispatchStore, + archive_path: &Path, + expected: &WorkspaceSnapshotMetadata, +) -> Result<()> { + let cache_root = store.workspace_snapshot_cache_root(); + let digest = expected.archive_sha256.to_ascii_lowercase(); + let cache_dir = cache_root.join(&digest); + let _lock = JobLock::exclusive(&workspace_snapshot_cache_lock_path(store, &digest))?; + + if let Some(mut record) = read_valid_snapshot_cache(&cache_dir, expected)? { + record.last_used_at = chrono::Utc::now().to_rfc3339(); + atomic_write_json( + &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + &record, + )?; + return Ok(()); + } + discard_snapshot_cache_entry(&cache_dir)?; + + if !sha256_file(archive_path)?.eq_ignore_ascii_case(&expected.archive_sha256) { + bail!("verified workspace snapshot changed before it entered the cache"); + } + let staging = cache_root.join(format!( + ".staging-{}-{}", + digest, + uuid::Uuid::new_v4().as_simple() + )); + create_private_dir(&staging)?; + let cached_archive = staging.join(CACHE_ARCHIVE_FILE); + let result = (|| -> Result<()> { + if fs::hard_link(archive_path, &cached_archive).is_err() { + fs::copy(archive_path, &cached_archive).with_context(|| { + format!( + "copy verified workspace snapshot into cache {}", + cached_archive.display() + ) + })?; + } + set_private_file_permissions(&cached_archive)?; + let now = chrono::Utc::now().to_rfc3339(); + atomic_write_json( + &staging.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + &WorkspaceSnapshotCacheRecord { + metadata: expected.clone(), + created_at: now.clone(), + last_used_at: now, + }, + )?; + sync_directory(&staging)?; + fs::rename(&staging, &cache_dir).with_context(|| { + format!( + "publish dispatch workspace cache {} -> {}", + staging.display(), + cache_dir.display() + ) + })?; + sync_directory(&cache_root)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn read_valid_snapshot_cache( + cache_dir: &Path, + expected: &WorkspaceSnapshotMetadata, +) -> Result> { + let directory = match fs::symlink_metadata(cache_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("inspect dispatch workspace cache"), + }; + if directory.file_type().is_symlink() || !directory.is_dir() { + return Ok(None); + } + let record = match read_json::( + &cache_dir.join(WORKSPACE_SNAPSHOT_CACHE_RECORD_FILE), + ) { + Ok(record) => record, + Err(_) => return Ok(None), + }; + if record.metadata != *expected { + return Ok(None); + } + let archive_path = cache_dir.join(CACHE_ARCHIVE_FILE); + let archive = match fs::symlink_metadata(&archive_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("inspect cached workspace snapshot"), + }; + if archive.file_type().is_symlink() + || !archive.is_file() + || archive.len() != expected.archive_size + { + return Ok(None); + } + if !sha256_file(&archive_path)?.eq_ignore_ascii_case(&expected.archive_sha256) { + return Ok(None); + } + Ok(Some(record)) +} + +fn discard_snapshot_cache_entry(cache_dir: &Path) -> Result<()> { + match fs::symlink_metadata(cache_dir) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + fs::remove_file(cache_dir).with_context(|| { + format!( + "remove invalid dispatch workspace cache {}", + cache_dir.display() + ) + }) + } + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(cache_dir).with_context(|| { + format!( + "remove invalid dispatch workspace cache {}", + cache_dir.display() + ) + }), + Ok(_) => bail!("dispatch workspace cache entry is an unsupported file type"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("inspect invalid dispatch workspace cache"), + } +} + +fn workspace_snapshot_cache_lock_path(store: &DispatchStore, digest: &str) -> PathBuf { + store + .workspace_snapshot_cache_root() + .join(format!(".{digest}.lock")) +} + fn remove_stale_staging_directories(upload_dir: &Path) -> Result<()> { for entry in fs::read_dir(upload_dir) .with_context(|| format!("read workspace upload directory {}", upload_dir.display()))? @@ -705,6 +923,86 @@ mod tests { assert!(record.workspace_path.is_some()); } + #[test] + fn identical_jobs_reuse_one_verified_target_archive_but_keep_writes_isolated() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("file.txt"), b"shared input").expect("source file"); + let source_archive = temp.path().join("source.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &source_archive).expect("snapshot"); + + let first = begin_in_store( + &store, + DispatchWorkspaceBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + metadata: metadata.clone(), + }, + ) + .expect("begin first upload"); + assert_eq!(first.offset, 0); + fs::copy(&source_archive, &first.upload_path).expect("upload first snapshot"); + materialize_in_store(&store, "job-1").expect("materialize first job"); + + let cache_dir = store + .workspace_snapshot_cache_root() + .join(&metadata.archive_sha256); + assert!(cache_dir.join(CACHE_ARCHIVE_FILE).is_file()); + assert_eq!( + fs::read_dir(store.workspace_snapshot_cache_root()) + .expect("read cache") + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .count(), + 1 + ); + + let second = begin_in_store( + &store, + DispatchWorkspaceBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-2".to_string(), + metadata: metadata.clone(), + }, + ) + .expect("begin cached upload"); + assert_eq!( + second.offset, metadata.archive_size, + "a cache hit must tell the controller that no bytes remain" + ); + + fs::write( + store + .workspace_upload_dir("job-1") + .expect("first workspace") + .join(CURRENT_WORKSPACE_DIR) + .join("file.txt"), + b"job one changed", + ) + .expect("modify first job"); + materialize_in_store(&store, "job-2").expect("materialize cached job"); + assert_eq!( + fs::read( + store + .workspace_upload_dir("job-2") + .expect("second workspace") + .join(CURRENT_WORKSPACE_DIR) + .join("file.txt") + ) + .expect("read second job"), + b"shared input", + "cache reuse must not share the writable job workspace" + ); + assert!(cache_dir.join(CACHE_ARCHIVE_FILE).is_file()); + assert!(!store + .workspace_upload_dir("job-2") + .expect("second workspace") + .join(UPLOAD_ARCHIVE_FILE) + .exists()); + } + #[test] fn materialization_failure_is_persisted_for_commit_pollers() { let temp = tempfile::tempdir().expect("tempdir"); From eeef808ce988a272827cd8c8bc2e6b7152e4663f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 23:27:54 -0700 Subject: [PATCH 11/11] perf(dispatch): reuse controller workspace snapshots --- docs/architecture/detached-task-dispatch.md | 10 + .../assembly/core/src/service/dispatch/mod.rs | 342 +++++++++++++++-- .../services-core/src/dispatch_workspace.rs | 356 ++++++++++++++++-- 3 files changed, 646 insertions(+), 62 deletions(-) diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index 03ebefc52b..99ca167411 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -104,6 +104,16 @@ the package fail, but coordinated edits across multiple files can still span the traversal interval. Callers that require an application-consistent source must quiesce the source or select a filesystem snapshot as the source path. +The controller retains the latest verified archive for each canonical source +path and capture mode. Before packaging a later job, it recomputes a lightweight +fingerprint from the selected paths and their filesystem identity, size, +executable state, and write/change timestamps. An unchanged fingerprint +hard-links the cached immutable archive into the new job instead of rereading +and recompressing every file. Source mode ignores changes below ignored paths; +exact mode observes them. A selected entry change invalidates and atomically +replaces the cache. The per-job link remains immutable during submission, so a +later cache replacement cannot change an in-flight job's bytes. + SSH transports the archive with SFTP after `workspace-begin`. Account-device RPC uses bounded base64 chunks inside the existing end-to-end encrypted `HostInvoke` envelope. Neither transport puts source bytes in command-line diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index 4087f1eea1..7d018ba458 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -8,12 +8,14 @@ use std::path::{Path, PathBuf}; use anyhow::Context as _; use bitfun_services_core::dispatch_workspace::{ - create_exact_workspace_snapshot, create_source_workspace_snapshot, sha256_file, - WorkspaceSnapshotMetadata, + exact_workspace_snapshot_source_fingerprint, prepare_exact_workspace_snapshot, + prepare_source_workspace_snapshot, sha256_file, source_workspace_snapshot_source_fingerprint, + WorkspaceSnapshotMetadata, WorkspaceSnapshotSourceFingerprint, }; use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use thiserror::Error; use tokio::fs; @@ -52,6 +54,7 @@ pub use target::{DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliver const PROMPT_PREVIEW_CHARS: usize = 160; const OUTBOUND_WORKSPACE_UPLOADS_DIR: &str = ".workspace-uploads"; +const OUTBOUND_WORKSPACE_CACHE_DIR: &str = ".workspace-cache"; /// Where pulled result bundles are staged before the user applies them. pub(super) const OUTBOUND_RESULTS_DIR: &str = ".results"; const TERMINAL_OUTBOUND_RETENTION_DAYS: i64 = 30; @@ -69,6 +72,19 @@ struct OutboundWorkspaceSnapshotRecord { #[serde(default)] capture_mode: DispatchWorkspaceSnapshotCaptureMode, metadata: WorkspaceSnapshotMetadata, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_fingerprint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OutboundWorkspaceCacheRecord { + source_workspace_path: String, + capture_mode: DispatchWorkspaceSnapshotCaptureMode, + source_fingerprint: WorkspaceSnapshotSourceFingerprint, + metadata: WorkspaceSnapshotMetadata, + created_at: DateTime, + last_used_at: DateTime, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -79,6 +95,15 @@ pub enum DispatchWorkspaceSnapshotCaptureMode { Exact, } +impl DispatchWorkspaceSnapshotCaptureMode { + fn cache_key_label(self) -> &'static [u8] { + match self { + Self::Source => b"source", + Self::Exact => b"exact", + } + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct DispatchTargetJobEntry { @@ -409,6 +434,16 @@ impl OutboundDispatchStore { "snapshot source cannot contain the controller dispatch staging directory" ); } + let cache = self.root.join(OUTBOUND_WORKSPACE_CACHE_DIR); + fs::create_dir_all(&cache).await?; + harden_directory_permissions(&cache).await?; + let cache = tokio::task::spawn_blocking(move || cache.canonicalize()) + .await + .map_err(|error| anyhow::anyhow!("snapshot cache path task failed: {error}"))? + .map_err(|error| anyhow::anyhow!("resolve snapshot cache directory: {error}"))?; + if cache.starts_with(&source) { + anyhow::bail!("snapshot source cannot contain the controller snapshot cache"); + } let record_path = uploads.join(format!("{job_id}.json")); let archive_path = uploads.join(format!("{job_id}.tar.gz")); let _lock = self @@ -424,24 +459,8 @@ impl OutboundDispatchStore { if record.source_workspace_path != source_wire || record.capture_mode != capture_mode { anyhow::bail!("dispatch jobId is already bound to another workspace snapshot"); } - let archive = archive_path.clone(); - let expected = record.metadata.clone(); - let valid = tokio::task::spawn_blocking(move || -> anyhow::Result { - let metadata = match std::fs::symlink_metadata(&archive) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(error.into()), - }; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() != expected.archive_size - { - return Ok(false); - } - Ok(sha256_file(&archive)?.eq_ignore_ascii_case(&expected.archive_sha256)) - }) - .await - .map_err(|error| anyhow::anyhow!("snapshot verification task failed: {error}"))??; + let valid = + snapshot_archive_is_valid(archive_path.clone(), record.metadata.clone()).await?; if valid { return Ok(PreparedOutboundWorkspaceSnapshot { archive_path, @@ -454,31 +473,116 @@ impl OutboundDispatchStore { let _ = fs::remove_file(&archive_path).await; } + let cache_key = outbound_workspace_cache_key(&source_wire, capture_mode); + let cache_record_path = cache.join(format!("{cache_key}.json")); + let cache_archive_path = cache.join(format!("{cache_key}.tar.gz")); + let _cache_lock = self + .json_store + .acquire_cross_process_lock(&cache_record_path) + .await?; + let mut cached = match self + .json_store + .read_optional::(&cache_record_path) + .await + { + Ok(record) => record, + Err(error) => { + log::warn!( + "Ignoring unreadable outbound workspace cache record: path={} error={}", + cache_record_path.display(), + error + ); + None + } + }; + if cached.as_ref().is_some_and(|record| { + record.source_workspace_path != source_wire || record.capture_mode != capture_mode + }) { + log::warn!( + "Ignoring outbound workspace cache identity mismatch: path={}", + cache_record_path.display() + ); + cached = None; + } + if let Some(mut cached) = cached { + let current_fingerprint = + workspace_source_fingerprint(source.clone(), capture_mode).await?; + if current_fingerprint == cached.source_fingerprint + && snapshot_archive_is_valid(cache_archive_path.clone(), cached.metadata.clone()) + .await? + { + replace_snapshot_archive(&cache_archive_path, &archive_path).await?; + cached.last_used_at = Utc::now(); + self.json_store + .write_atomic_strict(&cache_record_path, &cached) + .await?; + harden_file_permissions(&cache_record_path).await?; + let record = OutboundWorkspaceSnapshotRecord { + source_workspace_path: source_wire, + capture_mode, + metadata: cached.metadata.clone(), + source_fingerprint: Some(cached.source_fingerprint), + }; + self.json_store + .write_atomic_strict(&record_path, &record) + .await?; + harden_file_permissions(&record_path).await?; + return Ok(PreparedOutboundWorkspaceSnapshot { + archive_path, + metadata: cached.metadata, + }); + } + } + + remove_file_if_present(&cache_record_path).await?; + remove_file_if_present(&cache_archive_path).await?; let package_source = source.clone(); let package_archive = archive_path.clone(); - let metadata = tokio::task::spawn_blocking(move || match capture_mode { + let prepared = tokio::task::spawn_blocking(move || match capture_mode { DispatchWorkspaceSnapshotCaptureMode::Source => { - create_source_workspace_snapshot(&package_source, &package_archive) + prepare_source_workspace_snapshot(&package_source, &package_archive) } DispatchWorkspaceSnapshotCaptureMode::Exact => { - create_exact_workspace_snapshot(&package_source, &package_archive) + prepare_exact_workspace_snapshot(&package_source, &package_archive) } }) .await .map_err(|error| anyhow::anyhow!("snapshot packaging task failed: {error}"))??; + harden_file_permissions(&archive_path).await?; + publish_snapshot_cache_archive( + &archive_path, + &cache_archive_path, + &cache, + &cache_key, + job_id, + ) + .await?; + let now = Utc::now(); + let cache_record = OutboundWorkspaceCacheRecord { + source_workspace_path: source_wire.clone(), + capture_mode, + source_fingerprint: prepared.source_fingerprint.clone(), + metadata: prepared.metadata.clone(), + created_at: now, + last_used_at: now, + }; + self.json_store + .write_atomic_strict(&cache_record_path, &cache_record) + .await?; + harden_file_permissions(&cache_record_path).await?; let record = OutboundWorkspaceSnapshotRecord { source_workspace_path: source_wire, capture_mode, - metadata: metadata.clone(), + metadata: prepared.metadata.clone(), + source_fingerprint: Some(prepared.source_fingerprint), }; self.json_store .write_atomic_strict(&record_path, &record) .await?; harden_file_permissions(&record_path).await?; - harden_file_permissions(&archive_path).await?; Ok(PreparedOutboundWorkspaceSnapshot { archive_path, - metadata, + metadata: prepared.metadata, }) } @@ -534,6 +638,101 @@ impl OutboundDispatchStore { } } +fn outbound_workspace_cache_key( + source_workspace_path: &str, + capture_mode: DispatchWorkspaceSnapshotCaptureMode, +) -> String { + let mut digest = Sha256::new(); + digest.update(b"bitfun-dispatch-outbound-workspace-cache"); + digest.update(capture_mode.cache_key_label()); + digest.update((source_workspace_path.len() as u64).to_le_bytes()); + digest.update(source_workspace_path.as_bytes()); + format!("{:x}", digest.finalize()) +} + +async fn workspace_source_fingerprint( + source: PathBuf, + capture_mode: DispatchWorkspaceSnapshotCaptureMode, +) -> anyhow::Result { + tokio::task::spawn_blocking(move || match capture_mode { + DispatchWorkspaceSnapshotCaptureMode::Source => { + source_workspace_snapshot_source_fingerprint(&source) + } + DispatchWorkspaceSnapshotCaptureMode::Exact => { + exact_workspace_snapshot_source_fingerprint(&source) + } + }) + .await + .map_err(|error| anyhow::anyhow!("snapshot fingerprint task failed: {error}"))? +} + +async fn snapshot_archive_is_valid( + archive: PathBuf, + expected: WorkspaceSnapshotMetadata, +) -> anyhow::Result { + tokio::task::spawn_blocking(move || -> anyhow::Result { + let metadata = match std::fs::symlink_metadata(&archive) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() != expected.archive_size + { + return Ok(false); + } + Ok(sha256_file(&archive)?.eq_ignore_ascii_case(&expected.archive_sha256)) + }) + .await + .map_err(|error| anyhow::anyhow!("snapshot verification task failed: {error}"))? +} + +async fn replace_snapshot_archive(source: &Path, destination: &Path) -> anyhow::Result<()> { + remove_file_if_present(destination).await?; + if let Err(link_error) = fs::hard_link(source, destination).await { + if let Err(copy_error) = fs::copy(source, destination).await { + let _ = fs::remove_file(destination).await; + anyhow::bail!( + "copy snapshot archive {} to {} after hard-link failed ({link_error}): \ + {copy_error}", + source.display(), + destination.display() + ); + } + } + harden_file_permissions(destination).await?; + Ok(()) +} + +async fn publish_snapshot_cache_archive( + source: &Path, + destination: &Path, + cache_directory: &Path, + cache_key: &str, + job_id: &str, +) -> anyhow::Result<()> { + let staging = cache_directory.join(format!(".{cache_key}.{job_id}.tmp")); + remove_file_if_present(&staging).await?; + replace_snapshot_archive(source, &staging).await?; + remove_file_if_present(destination).await?; + if let Err(error) = fs::rename(&staging, destination).await { + let _ = fs::remove_file(&staging).await; + return Err(error) + .with_context(|| format!("publish snapshot cache archive {}", destination.display())); + } + harden_file_permissions(destination).await?; + Ok(()) +} + +async fn remove_file_if_present(path: &Path) -> anyhow::Result<()> { + match fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + async fn adopt_target_jobs( store: &OutboundDispatchStore, target: &DispatchTarget, @@ -838,6 +1037,97 @@ mod tests { ); } + #[tokio::test] + async fn workspace_cache_reuses_unchanged_source_across_dispatch_jobs() { + let temp = tempfile::tempdir().expect("temp dir"); + let root = temp.path().join("outbound"); + let store = OutboundDispatchStore::new_in_root_for_tests(root.clone()); + let source = temp.path().join("workspace"); + std::fs::create_dir_all(source.join(".git")).expect("repository marker"); + std::fs::create_dir_all(source.join("target")).expect("ignored directory"); + std::fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); + std::fs::write(source.join("main.rs"), b"fn main() {}").expect("source"); + std::fs::write(source.join("target/app"), b"first build").expect("ignored output"); + let source_wire = source + .canonicalize() + .expect("canonical source") + .to_string_lossy() + .to_string(); + let cache_key = outbound_workspace_cache_key( + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ); + let cache_archive = root + .join(OUTBOUND_WORKSPACE_CACHE_DIR) + .join(format!("{cache_key}.tar.gz")); + + let first = store + .prepare_workspace_snapshot( + "job-1", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("first snapshot"); + let first_cache_metadata = std::fs::metadata(&cache_archive).expect("first cached archive"); + store + .remove_workspace_snapshot("job-1") + .await + .expect("remove first job snapshot"); + assert!( + cache_archive.exists(), + "removing a completed job must retain the reusable cache" + ); + + std::fs::write(source.join("target/app"), b"a different ignored build") + .expect("change ignored output"); + let second = store + .prepare_workspace_snapshot( + "job-2", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("cached snapshot"); + let second_cache_metadata = + std::fs::metadata(&cache_archive).expect("reused cached archive"); + assert_eq!(first.metadata, second.metadata); + assert_eq!( + first_cache_metadata.modified().expect("first modified"), + second_cache_metadata.modified().expect("second modified"), + "a cache hit must not recreate the archive" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + second_cache_metadata.ino(), + std::fs::metadata(&second.archive_path) + .expect("job archive") + .ino(), + "each job should hard-link the immutable cached archive" + ); + } + + std::fs::write( + source.join("main.rs"), + b"fn main() { println!(\"changed\"); }", + ) + .expect("change included source"); + let third = store + .prepare_workspace_snapshot( + "job-3", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("invalidated snapshot"); + assert_ne!( + second.metadata.archive_sha256, third.metadata.archive_sha256, + "an included source change must invalidate the cached archive" + ); + } + #[tokio::test] async fn rejects_path_traversal_job_ids() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/services/services-core/src/dispatch_workspace.rs b/src/crates/services/services-core/src/dispatch_workspace.rs index e7611bd6a4..ae2a2aaf63 100644 --- a/src/crates/services/services-core/src/dispatch_workspace.rs +++ b/src/crates/services/services-core/src/dispatch_workspace.rs @@ -18,6 +18,7 @@ use sha2::{Digest, Sha256}; use tar::{Archive, Builder, EntryType, Header}; pub const WORKSPACE_SNAPSHOT_FORMAT_VERSION: u32 = 1; +pub const WORKSPACE_SNAPSHOT_SOURCE_FINGERPRINT_VERSION: u32 = 1; pub const MAX_SNAPSHOT_FILES: u64 = 100_000; pub const MAX_SNAPSHOT_DIRECTORIES: u64 = 100_000; pub const MAX_SNAPSHOT_FILE_BYTES: u64 = 256 * 1024 * 1024; @@ -71,6 +72,24 @@ pub struct WorkspaceSnapshotMetadata { pub uncompressed_bytes: u64, } +/// Cheaply recomputable state of the source tree used to create a snapshot. +/// +/// This is a controller-local cache key, not part of the target wire metadata. +/// It covers the selected portable paths plus file identity, size, write/change +/// timestamps, and executable state without rereading file contents. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceSnapshotSourceFingerprint { + pub format_version: u32, + pub sha256: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreparedWorkspaceSnapshot { + pub metadata: WorkspaceSnapshotMetadata, + pub source_fingerprint: WorkspaceSnapshotSourceFingerprint, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum WorkspaceSnapshotCaptureMode { Source, @@ -434,6 +453,13 @@ pub fn create_exact_workspace_snapshot( source: &Path, archive_path: &Path, ) -> Result { + Ok(prepare_exact_workspace_snapshot(source, archive_path)?.metadata) +} + +pub fn prepare_exact_workspace_snapshot( + source: &Path, + archive_path: &Path, +) -> Result { create_workspace_snapshot(source, archive_path, WorkspaceSnapshotCaptureMode::Exact) } @@ -447,14 +473,33 @@ pub fn create_source_workspace_snapshot( source: &Path, archive_path: &Path, ) -> Result { + Ok(prepare_source_workspace_snapshot(source, archive_path)?.metadata) +} + +pub fn prepare_source_workspace_snapshot( + source: &Path, + archive_path: &Path, +) -> Result { create_workspace_snapshot(source, archive_path, WorkspaceSnapshotCaptureMode::Source) } +pub fn exact_workspace_snapshot_source_fingerprint( + source: &Path, +) -> Result { + workspace_snapshot_source_fingerprint(source, WorkspaceSnapshotCaptureMode::Exact) +} + +pub fn source_workspace_snapshot_source_fingerprint( + source: &Path, +) -> Result { + workspace_snapshot_source_fingerprint(source, WorkspaceSnapshotCaptureMode::Source) +} + fn create_workspace_snapshot( source: &Path, archive_path: &Path, capture_mode: WorkspaceSnapshotCaptureMode, -) -> Result { +) -> Result { let result = create_workspace_snapshot_inner(source, archive_path, capture_mode); if result.is_err() { let _ = fs::remove_file(archive_path); @@ -466,7 +511,7 @@ fn create_workspace_snapshot_inner( source: &Path, archive_path: &Path, capture_mode: WorkspaceSnapshotCaptureMode, -) -> Result { +) -> Result { let source_metadata = fs::symlink_metadata(source) .with_context(|| format!("inspect workspace {}", source.display()))?; if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { @@ -494,36 +539,12 @@ fn create_workspace_snapshot_inner( let mut archive = Builder::new(encoder); archive.mode(tar::HeaderMode::Deterministic); - let mut walk = WalkBuilder::new(&source); - walk.hidden(false).follow_links(false); - match capture_mode { - WorkspaceSnapshotCaptureMode::Source => { - walk.ignore(true) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .require_git(false) - .parents(false); - } - WorkspaceSnapshotCaptureMode::Exact => { - walk.ignore(false) - .git_ignore(false) - .git_global(false) - .git_exclude(false) - .parents(false); - } - } - walk.sort_by_file_path(|left, right| left.cmp(right)); - let filter_root = source.clone(); - walk.filter_entry(move |entry| { - entry.path() == filter_root || entry.file_name().to_str() != Some(".git") - }); - let mut entries = Vec::new(); let mut file_count = 0_u64; let mut directory_count = 0_u64; let mut uncompressed_bytes = 0_u64; - for walked in walk.build() { + let mut source_fingerprint = new_source_fingerprint(capture_mode); + for walked in workspace_walk(&source, capture_mode) { let walked = walked.context("walk workspace for dispatch snapshot")?; let path = walked.path(); if path == source { @@ -550,6 +571,7 @@ fn create_workspace_snapshot_inner( ); } append_directory(&mut archive, &relative_wire)?; + update_directory_source_fingerprint(&mut source_fingerprint, &relative_wire); entries.push(WorkspaceSnapshotEntry { path: relative_wire, kind: WorkspaceSnapshotEntryKind::Directory, @@ -588,6 +610,12 @@ fn create_workspace_snapshot_inner( ); } let executable = is_executable(&metadata); + update_file_source_fingerprint( + &mut source_fingerprint, + &relative_wire, + &metadata, + executable, + )?; let sha256 = append_file(&mut archive, path, &relative_wire, &metadata, executable)?; entries.push(WorkspaceSnapshotEntry { path: relative_wire, @@ -637,17 +665,223 @@ fn create_workspace_snapshot_inner( ); } let archive_sha256 = sha256_file(archive_path)?; - Ok(WorkspaceSnapshotMetadata { - format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, - archive_size, - archive_sha256, - manifest_sha256, - file_count, - directory_count, - uncompressed_bytes, + Ok(PreparedWorkspaceSnapshot { + metadata: WorkspaceSnapshotMetadata { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + archive_size, + archive_sha256, + manifest_sha256, + file_count, + directory_count, + uncompressed_bytes, + }, + source_fingerprint: finish_source_fingerprint(source_fingerprint), }) } +fn workspace_snapshot_source_fingerprint( + source: &Path, + capture_mode: WorkspaceSnapshotCaptureMode, +) -> Result { + let source_metadata = fs::symlink_metadata(source) + .with_context(|| format!("inspect workspace {}", source.display()))?; + if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { + bail!( + "workspace snapshot source is not a real directory: {}", + source.display() + ); + } + let source = source + .canonicalize() + .with_context(|| format!("resolve workspace {}", source.display()))?; + let mut fingerprint = new_source_fingerprint(capture_mode); + let mut file_count = 0_u64; + let mut directory_count = 0_u64; + let mut uncompressed_bytes = 0_u64; + for walked in workspace_walk(&source, capture_mode) { + let walked = walked.context("walk workspace for dispatch snapshot fingerprint")?; + let path = walked.path(); + if path == source { + continue; + } + let relative = path + .strip_prefix(&source) + .with_context(|| format!("resolve snapshot path {}", path.display()))?; + let relative_wire = portable_relative_path(relative)?; + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspect snapshot entry {}", path.display()))?; + if metadata.file_type().is_symlink() { + bail!( + "workspace snapshot does not support symbolic link '{}'", + relative_wire + ); + } + if metadata.is_dir() { + directory_count = directory_count.saturating_add(1); + if directory_count > MAX_SNAPSHOT_DIRECTORIES { + bail!( + "workspace snapshot exceeds the {} directory limit", + MAX_SNAPSHOT_DIRECTORIES + ); + } + update_directory_source_fingerprint(&mut fingerprint, &relative_wire); + continue; + } + if !metadata.is_file() { + bail!( + "workspace snapshot contains unsupported special file '{}'", + relative_wire + ); + } + if metadata.len() > MAX_SNAPSHOT_FILE_BYTES { + bail!( + "workspace snapshot file '{}' exceeds the {} MiB per-file limit", + relative_wire, + MAX_SNAPSHOT_FILE_BYTES / (1024 * 1024) + ); + } + file_count = file_count.saturating_add(1); + if file_count > MAX_SNAPSHOT_FILES { + bail!( + "workspace snapshot exceeds the {} file limit", + MAX_SNAPSHOT_FILES + ); + } + uncompressed_bytes = uncompressed_bytes.saturating_add(metadata.len()); + if uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { + bail!( + "workspace snapshot exceeds the {} MiB uncompressed limit", + MAX_SNAPSHOT_UNCOMPRESSED_BYTES / (1024 * 1024) + ); + } + update_file_source_fingerprint( + &mut fingerprint, + &relative_wire, + &metadata, + is_executable(&metadata), + )?; + } + Ok(finish_source_fingerprint(fingerprint)) +} + +fn workspace_walk(source: &Path, capture_mode: WorkspaceSnapshotCaptureMode) -> ignore::Walk { + let mut walk = WalkBuilder::new(source); + walk.hidden(false).follow_links(false); + match capture_mode { + WorkspaceSnapshotCaptureMode::Source => { + walk.ignore(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .require_git(false) + .parents(false); + } + WorkspaceSnapshotCaptureMode::Exact => { + walk.ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false); + } + } + walk.sort_by_file_path(|left, right| left.cmp(right)); + let filter_root = source.to_path_buf(); + walk.filter_entry(move |entry| { + entry.path() == filter_root || entry.file_name().to_str() != Some(".git") + }); + walk.build() +} + +fn new_source_fingerprint(capture_mode: WorkspaceSnapshotCaptureMode) -> Sha256 { + let mut fingerprint = Sha256::new(); + fingerprint.update(b"bitfun-dispatch-workspace-source-fingerprint"); + fingerprint.update(WORKSPACE_SNAPSHOT_SOURCE_FINGERPRINT_VERSION.to_le_bytes()); + fingerprint.update(match capture_mode { + WorkspaceSnapshotCaptureMode::Source => b"source".as_slice(), + WorkspaceSnapshotCaptureMode::Exact => b"exact".as_slice(), + }); + fingerprint +} + +fn update_directory_source_fingerprint(fingerprint: &mut Sha256, relative_wire: &str) { + update_fingerprint_field(fingerprint, b"directory"); + update_fingerprint_field(fingerprint, relative_wire.as_bytes()); +} + +fn update_file_source_fingerprint( + fingerprint: &mut Sha256, + relative_wire: &str, + metadata: &fs::Metadata, + executable: bool, +) -> Result<()> { + update_fingerprint_field(fingerprint, b"file"); + update_fingerprint_field(fingerprint, relative_wire.as_bytes()); + fingerprint.update(metadata.len().to_le_bytes()); + fingerprint.update([u8::from(executable)]); + update_platform_file_fingerprint(fingerprint, metadata) +} + +#[cfg(unix)] +fn update_platform_file_fingerprint( + fingerprint: &mut Sha256, + metadata: &fs::Metadata, +) -> Result<()> { + use std::os::unix::fs::MetadataExt; + fingerprint.update(metadata.dev().to_le_bytes()); + fingerprint.update(metadata.ino().to_le_bytes()); + fingerprint.update(metadata.mode().to_le_bytes()); + fingerprint.update(metadata.mtime().to_le_bytes()); + fingerprint.update(metadata.mtime_nsec().to_le_bytes()); + fingerprint.update(metadata.ctime().to_le_bytes()); + fingerprint.update(metadata.ctime_nsec().to_le_bytes()); + Ok(()) +} + +#[cfg(windows)] +fn update_platform_file_fingerprint( + fingerprint: &mut Sha256, + metadata: &fs::Metadata, +) -> Result<()> { + use std::os::windows::fs::MetadataExt; + fingerprint.update(metadata.file_attributes().to_le_bytes()); + fingerprint.update(metadata.creation_time().to_le_bytes()); + fingerprint.update(metadata.last_write_time().to_le_bytes()); + fingerprint.update(metadata.file_size().to_le_bytes()); + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn update_platform_file_fingerprint( + fingerprint: &mut Sha256, + metadata: &fs::Metadata, +) -> Result<()> { + use std::time::UNIX_EPOCH; + let modified = metadata + .modified() + .context("read workspace file modification time")?; + let (before_epoch, duration) = match modified.duration_since(UNIX_EPOCH) { + Ok(duration) => (false, duration), + Err(error) => (true, error.duration()), + }; + fingerprint.update([u8::from(before_epoch)]); + fingerprint.update(duration.as_secs().to_le_bytes()); + fingerprint.update(duration.subsec_nanos().to_le_bytes()); + fingerprint.update([u8::from(metadata.permissions().readonly())]); + Ok(()) +} + +fn update_fingerprint_field(fingerprint: &mut Sha256, value: &[u8]) { + fingerprint.update((value.len() as u64).to_le_bytes()); + fingerprint.update(value); +} + +fn finish_source_fingerprint(fingerprint: Sha256) -> WorkspaceSnapshotSourceFingerprint { + WorkspaceSnapshotSourceFingerprint { + format_version: WORKSPACE_SNAPSHOT_SOURCE_FINGERPRINT_VERSION, + sha256: format!("{:x}", fingerprint.finalize()), + } +} + /// Verify and extract a snapshot into a brand-new staging directory. /// /// Callers publish the directory atomically only after this returns. This @@ -1256,6 +1490,56 @@ mod tests { assert!(!destination.join("target").exists()); } + #[test] + fn source_fingerprint_reuses_ignored_state_and_invalidates_included_changes() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(source.join(".git")).expect("repository marker"); + fs::create_dir_all(source.join("target")).expect("ignored directory"); + fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); + fs::write(source.join("source.rs"), b"fn main() {}").expect("source"); + fs::write(source.join("target/app"), b"first build").expect("ignored output"); + let archive = temp.path().join("source-snapshot.tar.gz"); + + let prepared = + prepare_source_workspace_snapshot(&source, &archive).expect("prepare source snapshot"); + let unchanged = + source_workspace_snapshot_source_fingerprint(&source).expect("source fingerprint"); + assert_eq!(prepared.source_fingerprint, unchanged); + + fs::write(source.join("target/app"), b"a different ignored build") + .expect("change ignored output"); + assert_eq!( + unchanged, + source_workspace_snapshot_source_fingerprint(&source) + .expect("fingerprint after ignored change") + ); + + let exact_before = + exact_workspace_snapshot_source_fingerprint(&source).expect("exact fingerprint"); + fs::write( + source.join("target/app"), + b"another ignored build with a different size", + ) + .expect("change exact input"); + assert_ne!( + exact_before, + exact_workspace_snapshot_source_fingerprint(&source) + .expect("exact fingerprint after ignored change") + ); + + fs::write( + source.join("source.rs"), + b"fn main() { println!(\"changed\"); }", + ) + .expect("change source"); + assert_ne!( + unchanged, + source_workspace_snapshot_source_fingerprint(&source) + .expect("fingerprint after source change") + ); + } + #[test] fn result_bundle_reports_adds_edits_and_deletes_without_git() { let temp = tempfile::tempdir().expect("tempdir");