mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
Make chat work directory per-run instead of global
The work directory was stored in a single shared config file, so setting it in one chat changed it for every chat and new chats inherited a stale value. Store it as run-scoped metadata: captured on the start event at creation and updatable mid-chat via an appended workdir-changed event. The runtime reads the per-run value from AgentState, and the chat input reads/writes it against the run (pending chats pass it into runs:create).
This commit is contained in:
parent
b9c4099c3b
commit
ce1913d4e0
9 changed files with 113 additions and 40 deletions
|
|
@ -524,6 +524,10 @@ export function setupIpcHandlers() {
|
||||||
'runs:fetch': async (_event, args) => {
|
'runs:fetch': async (_event, args) => {
|
||||||
return runsCore.fetchRun(args.runId);
|
return runsCore.fetchRun(args.runId);
|
||||||
},
|
},
|
||||||
|
'runs:setWorkdir': async (_event, args) => {
|
||||||
|
await runsCore.setWorkdir(args.runId, args.workingDirectory);
|
||||||
|
return { success: true as const };
|
||||||
|
},
|
||||||
'runs:list': async (_event, args) => {
|
'runs:list': async (_event, args) => {
|
||||||
return runsCore.listRuns(args.cursor);
|
return runsCore.listRuns(args.cursor);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -970,6 +970,7 @@ function App() {
|
||||||
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
|
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
|
||||||
const chatDraftsRef = useRef(new Map<string, string>())
|
const chatDraftsRef = useRef(new Map<string, string>())
|
||||||
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
|
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
|
||||||
|
const pendingWorkDirByTabRef = useRef(new Map<string, string>())
|
||||||
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||||
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
||||||
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
||||||
|
|
@ -2354,10 +2355,13 @@ function App() {
|
||||||
let newRunCreatedAt: string | null = null
|
let newRunCreatedAt: string | null = null
|
||||||
if (!currentRunId) {
|
if (!currentRunId) {
|
||||||
const selected = selectedModelByTabRef.current.get(submitTabId)
|
const selected = selectedModelByTabRef.current.get(submitTabId)
|
||||||
|
const pendingWorkDir = pendingWorkDirByTabRef.current.get(submitTabId)
|
||||||
const run = await window.ipc.invoke('runs:create', {
|
const run = await window.ipc.invoke('runs:create', {
|
||||||
agentId,
|
agentId,
|
||||||
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
||||||
|
...(pendingWorkDir ? { workingDirectory: pendingWorkDir } : {}),
|
||||||
})
|
})
|
||||||
|
pendingWorkDirByTabRef.current.delete(submitTabId)
|
||||||
currentRunId = run.id
|
currentRunId = run.id
|
||||||
newRunCreatedAt = run.createdAt
|
newRunCreatedAt = run.createdAt
|
||||||
setRunId(currentRunId)
|
setRunId(currentRunId)
|
||||||
|
|
@ -2666,6 +2670,7 @@ function App() {
|
||||||
})
|
})
|
||||||
chatDraftsRef.current.delete(tabId)
|
chatDraftsRef.current.delete(tabId)
|
||||||
selectedModelByTabRef.current.delete(tabId)
|
selectedModelByTabRef.current.delete(tabId)
|
||||||
|
pendingWorkDirByTabRef.current.delete(tabId)
|
||||||
chatScrollTopByTabRef.current.delete(tabId)
|
chatScrollTopByTabRef.current.delete(tabId)
|
||||||
setToolOpenByTab((prev) => {
|
setToolOpenByTab((prev) => {
|
||||||
if (!(tabId in prev)) return prev
|
if (!(tabId in prev)) return prev
|
||||||
|
|
@ -5300,6 +5305,13 @@ function App() {
|
||||||
selectedModelByTabRef.current.delete(tab.id)
|
selectedModelByTabRef.current.delete(tab.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onWorkDirChange={(dir) => {
|
||||||
|
if (dir) {
|
||||||
|
pendingWorkDirByTabRef.current.set(tab.id, dir)
|
||||||
|
} else {
|
||||||
|
pendingWorkDirByTabRef.current.delete(tab.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
isRecording={isActive && isRecording}
|
isRecording={isActive && isRecording}
|
||||||
recordingText={isActive ? voice.interimText : undefined}
|
recordingText={isActive ? voice.interimText : undefined}
|
||||||
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
||||||
|
|
@ -5360,6 +5372,13 @@ function App() {
|
||||||
selectedModelByTabRef.current.delete(tabId)
|
selectedModelByTabRef.current.delete(tabId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onWorkDirChangeForTab={(tabId, dir) => {
|
||||||
|
if (dir) {
|
||||||
|
pendingWorkDirByTabRef.current.set(tabId, dir)
|
||||||
|
} else {
|
||||||
|
pendingWorkDirByTabRef.current.delete(tabId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||||
allPermissionRequests={allPermissionRequests}
|
allPermissionRequests={allPermissionRequests}
|
||||||
permissionResponses={permissionResponses}
|
permissionResponses={permissionResponses}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,9 @@ interface ChatInputInnerProps {
|
||||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||||
|
/** Fired when the user sets/clears the work directory before a run exists, so the
|
||||||
|
* parent can pass it into runs:create. Once a run exists, changes go straight to the run. */
|
||||||
|
onWorkDirChange?: (dir: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatInputInner({
|
function ChatInputInner({
|
||||||
|
|
@ -159,6 +162,7 @@ function ChatInputInner({
|
||||||
onToggleTts,
|
onToggleTts,
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
|
onWorkDirChange,
|
||||||
}: ChatInputInnerProps) {
|
}: ChatInputInnerProps) {
|
||||||
const controller = usePromptInputController()
|
const controller = usePromptInputController()
|
||||||
const message = controller.textInput.value
|
const message = controller.textInput.value
|
||||||
|
|
@ -256,21 +260,22 @@ function ChatInputInner({
|
||||||
return () => window.removeEventListener('models-config-changed', handler)
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
}, [loadModelConfig])
|
}, [loadModelConfig])
|
||||||
|
|
||||||
// Load currently configured work directory
|
// The work directory is run-scoped. For an existing run, load it from the run.
|
||||||
const loadWorkDir = useCallback(async () => {
|
// For a new chat (no run yet) the chosen directory lives in local state until
|
||||||
try {
|
// the run is created, and is reported up via onWorkDirChange so it can be
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' })
|
// passed into runs:create.
|
||||||
const parsed = JSON.parse(result.data)
|
|
||||||
const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
|
|
||||||
setWorkDir(value || null)
|
|
||||||
} catch {
|
|
||||||
setWorkDir(null)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWorkDir()
|
if (!runId) {
|
||||||
}, [isActive, loadWorkDir])
|
setWorkDir(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setWorkDir(run.workingDirectory ?? null)
|
||||||
|
}).catch(() => { /* legacy run or fetch failure — leave as-is */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [runId])
|
||||||
|
|
||||||
const handleSetWorkDir = useCallback(async () => {
|
const handleSetWorkDir = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -279,31 +284,33 @@ function ChatInputInner({
|
||||||
defaultPath: workDir ?? undefined,
|
defaultPath: workDir ?? undefined,
|
||||||
})
|
})
|
||||||
if (!chosen) return
|
if (!chosen) return
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
if (runId) {
|
||||||
path: 'config/workdir.json',
|
await window.ipc.invoke('runs:setWorkdir', { runId, workingDirectory: chosen })
|
||||||
data: JSON.stringify({ path: chosen }, null, 2),
|
} else {
|
||||||
})
|
onWorkDirChange?.(chosen)
|
||||||
|
}
|
||||||
setWorkDir(chosen)
|
setWorkDir(chosen)
|
||||||
toast.success(`Work directory set: ${chosen}`)
|
toast.success(`Work directory set: ${chosen}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set work directory', err)
|
console.error('Failed to set work directory', err)
|
||||||
toast.error('Failed to set work directory')
|
toast.error('Failed to set work directory')
|
||||||
}
|
}
|
||||||
}, [workDir])
|
}, [workDir, runId, onWorkDirChange])
|
||||||
|
|
||||||
const handleClearWorkDir = useCallback(async () => {
|
const handleClearWorkDir = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
if (runId) {
|
||||||
path: 'config/workdir.json',
|
await window.ipc.invoke('runs:setWorkdir', { runId, workingDirectory: null })
|
||||||
data: JSON.stringify({}, null, 2),
|
} else {
|
||||||
})
|
onWorkDirChange?.(null)
|
||||||
|
}
|
||||||
setWorkDir(null)
|
setWorkDir(null)
|
||||||
toast.success('Work directory cleared')
|
toast.success('Work directory cleared')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear work directory', err)
|
console.error('Failed to clear work directory', err)
|
||||||
toast.error('Failed to clear work directory')
|
toast.error('Failed to clear work directory')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [runId, onWorkDirChange])
|
||||||
|
|
||||||
// Check search tool availability (exa or signed-in via gateway)
|
// Check search tool availability (exa or signed-in via gateway)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -790,6 +797,7 @@ export interface ChatInputWithMentionsProps {
|
||||||
onToggleTts?: () => void
|
onToggleTts?: () => void
|
||||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||||
|
onWorkDirChange?: (dir: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInputWithMentions({
|
export function ChatInputWithMentions({
|
||||||
|
|
@ -819,6 +827,7 @@ export function ChatInputWithMentions({
|
||||||
onToggleTts,
|
onToggleTts,
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
|
onWorkDirChange,
|
||||||
}: ChatInputWithMentionsProps) {
|
}: ChatInputWithMentionsProps) {
|
||||||
return (
|
return (
|
||||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||||
|
|
@ -846,6 +855,7 @@ export function ChatInputWithMentions({
|
||||||
onToggleTts={onToggleTts}
|
onToggleTts={onToggleTts}
|
||||||
onTtsModeChange={onTtsModeChange}
|
onTtsModeChange={onTtsModeChange}
|
||||||
onSelectedModelChange={onSelectedModelChange}
|
onSelectedModelChange={onSelectedModelChange}
|
||||||
|
onWorkDirChange={onWorkDirChange}
|
||||||
/>
|
/>
|
||||||
</PromptInputProvider>
|
</PromptInputProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ interface ChatSidebarProps {
|
||||||
getInitialDraft?: (tabId: string) => string | undefined
|
getInitialDraft?: (tabId: string) => string | undefined
|
||||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||||
|
onWorkDirChangeForTab?: (tabId: string, dir: string | null) => void
|
||||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||||
|
|
@ -250,6 +251,7 @@ export function ChatSidebar({
|
||||||
getInitialDraft,
|
getInitialDraft,
|
||||||
onDraftChangeForTab,
|
onDraftChangeForTab,
|
||||||
onSelectedModelChangeForTab,
|
onSelectedModelChangeForTab,
|
||||||
|
onWorkDirChangeForTab,
|
||||||
pendingAskHumanRequests = new Map(),
|
pendingAskHumanRequests = new Map(),
|
||||||
allPermissionRequests = new Map(),
|
allPermissionRequests = new Map(),
|
||||||
permissionResponses = new Map(),
|
permissionResponses = new Map(),
|
||||||
|
|
@ -742,6 +744,7 @@ export function ChatSidebar({
|
||||||
initialDraft={getInitialDraft?.(tab.id)}
|
initialDraft={getInitialDraft?.(tab.id)}
|
||||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||||
|
onWorkDirChange={onWorkDirChangeForTab ? (dir) => onWorkDirChangeForTab(tab.id, dir) : undefined}
|
||||||
isRecording={isActive && isRecording}
|
isRecording={isActive && isRecording}
|
||||||
recordingText={isActive ? recordingText : undefined}
|
recordingText={isActive ? recordingText : undefined}
|
||||||
recordingState={isActive ? recordingState : undefined}
|
recordingState={isActive ? recordingState : undefined}
|
||||||
|
|
|
||||||
|
|
@ -36,19 +36,6 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.
|
||||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||||
|
|
||||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||||
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
|
|
||||||
|
|
||||||
function loadUserWorkDir(): string | null {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
|
||||||
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
|
||||||
const parsed = JSON.parse(raw) as { path?: unknown };
|
|
||||||
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
|
||||||
return value || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAgentNotesContext(): string | null {
|
function loadAgentNotesContext(): string | null {
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
|
|
@ -673,6 +660,7 @@ export class AgentState {
|
||||||
runProvider: string | null = null;
|
runProvider: string | null = null;
|
||||||
runUseCase: UseCase | null = null;
|
runUseCase: UseCase | null = null;
|
||||||
runSubUseCase: string | null = null;
|
runSubUseCase: string | null = null;
|
||||||
|
workingDirectory: string | null = null;
|
||||||
messages: z.infer<typeof MessageList> = [];
|
messages: z.infer<typeof MessageList> = [];
|
||||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||||
subflowStates: Record<string, AgentState> = {};
|
subflowStates: Record<string, AgentState> = {};
|
||||||
|
|
@ -790,6 +778,10 @@ export class AgentState {
|
||||||
this.runProvider = event.provider;
|
this.runProvider = event.provider;
|
||||||
this.runUseCase = event.useCase ?? null;
|
this.runUseCase = event.useCase ?? null;
|
||||||
this.runSubUseCase = event.subUseCase ?? null;
|
this.runSubUseCase = event.subUseCase ?? null;
|
||||||
|
this.workingDirectory = event.workingDirectory ?? null;
|
||||||
|
break;
|
||||||
|
case "workdir-changed":
|
||||||
|
this.workingDirectory = event.workingDirectory ?? null;
|
||||||
break;
|
break;
|
||||||
case "spawn-subflow":
|
case "spawn-subflow":
|
||||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||||
|
|
@ -1122,7 +1114,7 @@ export async function* streamAgent({
|
||||||
if (agentNotesContext) {
|
if (agentNotesContext) {
|
||||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||||
}
|
}
|
||||||
const userWorkDir = loadUserWorkDir();
|
const userWorkDir = state.workingDirectory;
|
||||||
if (userWorkDir) {
|
if (userWorkDir) {
|
||||||
loopLogger.log('injecting user work directory', userWorkDir);
|
loopLogger.log('injecting user work directory', userWorkDir);
|
||||||
instructionsWithDateTime += `\n\n# User Work Directory
|
instructionsWithDateTime += `\n\n# User Work Directory
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import path from "path";
|
||||||
import fsp from "fs/promises";
|
import fsp from "fs/promises";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import readline from "readline";
|
import readline from "readline";
|
||||||
import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js";
|
import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase, WorkdirChangedEvent } from "@x/shared/dist/runs.js";
|
||||||
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,6 +37,7 @@ export type CreateRunRepoOptions = {
|
||||||
provider: string;
|
provider: string;
|
||||||
useCase: z.infer<typeof UseCase>;
|
useCase: z.infer<typeof UseCase>;
|
||||||
subUseCase?: string;
|
subUseCase?: string;
|
||||||
|
workingDirectory?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runLogPath(runId: string): string {
|
function runLogPath(runId: string): string {
|
||||||
|
|
@ -206,6 +207,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
useCase: options.useCase,
|
useCase: options.useCase,
|
||||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
|
...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}),
|
||||||
subflow: [],
|
subflow: [],
|
||||||
ts,
|
ts,
|
||||||
};
|
};
|
||||||
|
|
@ -218,6 +220,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
useCase: options.useCase,
|
useCase: options.useCase,
|
||||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
|
...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}),
|
||||||
log: [start],
|
log: [start],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +247,14 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
};
|
};
|
||||||
const events: z.infer<typeof RunEvent>[] = [start, ...rawEvents.slice(1) as z.infer<typeof RunEvent>[]];
|
const events: z.infer<typeof RunEvent>[] = [start, ...rawEvents.slice(1) as z.infer<typeof RunEvent>[]];
|
||||||
const title = this.extractTitle(events);
|
const title = this.extractTitle(events);
|
||||||
|
// The current work directory is the start event's value, overridden by the
|
||||||
|
// most recent workdir-changed event (append-only log — last write wins).
|
||||||
|
let workingDirectory: string | undefined = start.workingDirectory || undefined;
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === 'workdir-changed') {
|
||||||
|
workingDirectory = (event as z.infer<typeof WorkdirChangedEvent>).workingDirectory || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
|
@ -253,6 +264,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
provider: start.provider,
|
provider: start.provider,
|
||||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||||
|
...(workingDirectory ? { workingDirectory } : {}),
|
||||||
log: events,
|
log: events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
|
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload, WorkdirChangedEvent } from "@x/shared/dist/runs.js";
|
||||||
import { IRunsRepo } from "./repo.js";
|
import { IRunsRepo } from "./repo.js";
|
||||||
import { IAgentRuntime } from "../agents/runtime.js";
|
import { IAgentRuntime } from "../agents/runtime.js";
|
||||||
import { IBus } from "../application/lib/bus.js";
|
import { IBus } from "../application/lib/bus.js";
|
||||||
|
|
@ -34,11 +34,23 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
provider,
|
provider,
|
||||||
useCase,
|
useCase,
|
||||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||||
|
...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}),
|
||||||
});
|
});
|
||||||
await bus.publish(run.log[0]);
|
await bus.publish(run.log[0]);
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setWorkdir(runId: string, workingDirectory: string | null): Promise<void> {
|
||||||
|
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||||
|
const event: z.infer<typeof WorkdirChangedEvent> = {
|
||||||
|
runId,
|
||||||
|
type: "workdir-changed",
|
||||||
|
subflow: [],
|
||||||
|
...(workingDirectory ? { workingDirectory } : {}),
|
||||||
|
};
|
||||||
|
await repo.appendEvents(runId, [event]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,15 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
res: Run,
|
res: Run,
|
||||||
},
|
},
|
||||||
|
'runs:setWorkdir': {
|
||||||
|
req: z.object({
|
||||||
|
runId: z.string(),
|
||||||
|
workingDirectory: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
}),
|
||||||
|
},
|
||||||
'runs:list': {
|
'runs:list': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export const StartEvent = BaseRunEvent.extend({
|
||||||
"knowledge_sync",
|
"knowledge_sync",
|
||||||
]).optional(),
|
]).optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
|
// Per-run work directory chosen at creation. Optional: a run may have none,
|
||||||
|
// and it can be changed later via WorkdirChangedEvent.
|
||||||
|
workingDirectory: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||||
|
|
@ -105,6 +108,12 @@ export const RunStoppedEvent = BaseRunEvent.extend({
|
||||||
reason: z.enum(["user-requested", "force-stopped"]).optional(),
|
reason: z.enum(["user-requested", "force-stopped"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const WorkdirChangedEvent = BaseRunEvent.extend({
|
||||||
|
type: z.literal("workdir-changed"),
|
||||||
|
// Absent/empty means the work directory was cleared.
|
||||||
|
workingDirectory: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const RunEvent = z.union([
|
export const RunEvent = z.union([
|
||||||
RunProcessingStartEvent,
|
RunProcessingStartEvent,
|
||||||
RunProcessingEndEvent,
|
RunProcessingEndEvent,
|
||||||
|
|
@ -121,6 +130,7 @@ export const RunEvent = z.union([
|
||||||
ToolPermissionResponseEvent,
|
ToolPermissionResponseEvent,
|
||||||
RunErrorEvent,
|
RunErrorEvent,
|
||||||
RunStoppedEvent,
|
RunStoppedEvent,
|
||||||
|
WorkdirChangedEvent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||||
|
|
@ -153,6 +163,7 @@ export const Run = z.object({
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
useCase: UseCase.optional(),
|
useCase: UseCase.optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
|
workingDirectory: z.string().optional(),
|
||||||
log: z.array(RunEvent),
|
log: z.array(RunEvent),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -172,4 +183,5 @@ export const CreateRunOptions = z.object({
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
useCase: UseCase.optional(),
|
useCase: UseCase.optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
|
workingDirectory: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue