Files
openchamber/packages/ui/src/stores/useMultiRunStore.ts
T
2026-06-26 12:16:42 +03:00

341 lines
12 KiB
TypeScript

import { create } from 'zustand';
import type { Session } from '@opencode-ai/sdk/v2';
import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store';
import { devtools } from 'zustand/middleware';
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
import { opencodeClient } from '@/lib/opencode/client';
import { getWorktreeSetupWaitEnabled, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
import { useSnippetsStore } from './useSnippetsStore';
import { useGlobalSessionsStore } from './useGlobalSessionsStore';
import { getMultiRunSessionTitle } from '@/lib/multirun/title';
import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs';
const toGitSafeSlug = (value: string): string => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50);
};
const toModelSlug = (providerID: string, modelID: string): string => {
const provider = toGitSafeSlug(providerID);
const model = toGitSafeSlug(modelID);
return `${provider}-${model}`.substring(0, 60);
};
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
return `${groupSlug}/${modelSlug}`;
};
const normalizePath = (value: string): string => {
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
const registerCreatedSession = (session: Session, directory: string): Session => {
const normalizedDirectory = normalizePath(directory);
const sessionDirectory = (session as Session & { directory?: string | null }).directory;
const sessionWithDirectory = typeof sessionDirectory === 'string' && sessionDirectory.trim().length > 0
? session
: ({ ...session, directory: normalizedDirectory } as Session);
registerSessionDirectory(session.id, normalizedDirectory);
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id);
useGlobalSessionsStore.getState().upsertSession(sessionWithDirectory);
try {
const store = getSyncChildStores().ensureChild(normalizedDirectory, { bootstrap: false });
store.setState((state) => {
const existingIndex = state.session.findIndex((candidate) => candidate.id === session.id);
if (existingIndex >= 0 && state.session[existingIndex] === sessionWithDirectory) {
return state;
}
const nextSessions = existingIndex >= 0
? state.session.map((candidate, index) => index === existingIndex ? sessionWithDirectory : candidate)
: [...state.session, sessionWithDirectory].sort((a, b) => a.id.localeCompare(b.id));
return {
session: nextSessions,
sessionTotal: Math.max(state.sessionTotal, nextSessions.length),
limit: Math.max(state.limit, nextSessions.length),
};
});
} catch {
// SyncProvider can be unavailable in tests or detached surfaces; the global
// session upsert above is enough for the sidebar to show the session.
}
return sessionWithDirectory;
};
const resolveActiveProject = (): ProjectRef | null => {
const projectsState = useProjectsStore.getState();
const activeProjectId = projectsState.activeProjectId;
if (!activeProjectId) return null;
const project = projectsState.projects.find((entry) => entry.id === activeProjectId);
if (project?.path) return { id: project.id, path: project.path };
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
if (currentDirectory && currentDirectory.trim().length > 0) {
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
return { id: `path:${normalized}`, path: normalized };
}
return null;
};
interface MultiRunState {
isLoading: boolean;
error: string | null;
}
interface MultiRunActions {
createMultiRun: (params: CreateMultiRunParams) => Promise<CreateMultiRunResult | null>;
clearError: () => void;
}
type MultiRunStore = MultiRunState & MultiRunActions;
export const useMultiRunStore = create<MultiRunStore>()(
devtools(
(set) => ({
isLoading: false,
error: null,
createMultiRun: async (params: CreateMultiRunParams) => {
const groupName = params.name.trim();
const { groups, agent, files, setupCommands } = params;
if (!groupName) {
set({ error: 'Group name is required' });
return null;
}
if (!groups || groups.length === 0) {
set({ error: 'At least one run group is required' });
return null;
}
for (let gi = 0; gi < groups.length; gi++) {
if (!groups[gi].prompt.trim()) {
set({ error: `Group ${gi + 1}: prompt is required` });
return null;
}
if (groups[gi].models.length < 1) {
set({ error: `Group ${gi + 1}: select at least 1 model` });
return null;
}
if (groups[gi].models.length > 5) {
set({ error: `Group ${gi + 1}: maximum 5 models allowed` });
return null;
}
}
set({ isLoading: true, error: null });
try {
const project = resolveActiveProject();
if (!project) {
set({ error: 'Select a project', isLoading: false });
return null;
}
const directory = project.path;
const isGit = await checkIsGitRepository(directory);
const shouldIsolateRuns = isGit && params.isolateRuns !== false;
const groupSlug = toGitSafeSlug(groupName);
const rootBranch = shouldIsolateRuns ? await getRootBranch(directory) : undefined;
const rootTrackingRemote = shouldIsolateRuns ? await resolveRootTrackingRemote(directory) : null;
const createdRuns: Array<{
sessionId: string;
worktreePath: string;
providerID: string;
modelID: string;
variant?: string;
prompt: string;
}> = [];
const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi];
const prompt = group.prompt;
const modelCounts = new Map<string, number>();
for (const model of group.models) {
const key = `${model.providerID}:${model.modelID}`;
modelCounts.set(key, (modelCounts.get(key) || 0) + 1);
}
const modelIndexes = new Map<string, number>();
for (const model of group.models) {
const key = `${model.providerID}:${model.modelID}`;
const count = modelCounts.get(key) || 1;
const index = (modelIndexes.get(key) || 0) + 1;
modelIndexes.set(key, index);
const modelSlug = toModelSlug(model.providerID, model.modelID);
const runGroup = groups.length > 1 ? `g${gi + 1}` : undefined;
const modelPart = count > 1
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
: generateWorktreeNameSeed(groupSlug, modelSlug);
const preferredName = runGroup
? `${runGroup}/${modelPart}`
: modelPart;
const sessionTitle = getMultiRunSessionTitle({
groupSlug,
runGroup,
providerID: model.providerID,
modelID: model.modelID,
index: count > 1 ? index : undefined,
});
try {
if (!shouldIsolateRuns) {
const session = await opencodeClient.withDirectory(
directory,
() => opencodeClient.createSession({ title: sessionTitle }),
);
registerCreatedSession(session, directory);
createdRuns.push({
sessionId: session.id,
worktreePath: directory,
providerID: model.providerID,
modelID: model.modelID,
variant: model.variant,
prompt,
});
continue;
}
const worktreeMetadata = await createWorktreeWithDefaults(project, {
preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
startRef: params.worktreeBaseBranch || 'HEAD',
setupCommands: commandsToRun,
returnAfterDirectoryCreated: true,
}, {
resolvedRootTrackingRemote: rootTrackingRemote,
});
const enrichedMetadata = {
...worktreeMetadata,
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
if (await getWorktreeSetupWaitEnabled(project)) {
await waitForWorktreeBootstrap(worktreeMetadata.path);
}
const session = await opencodeClient.withDirectory(
worktreeMetadata.path,
() => opencodeClient.createSession({ title: sessionTitle }),
);
registerCreatedSession(session, worktreeMetadata.path);
useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
createdRuns.push({
sessionId: session.id,
worktreePath: worktreeMetadata.path,
providerID: model.providerID,
modelID: model.modelID,
variant: model.variant,
prompt,
});
} catch (err) {
console.warn('[MultiRun] Failed to create session:', err);
}
}
}
const commandsToSave = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
if (commandsToSave.length > 0) {
saveWorktreeSetupCommands(project, commandsToSave).catch(() => {
console.warn('[MultiRun] Failed to save worktree setup commands');
});
}
const sessionIds = createdRuns.map((r) => r.sessionId);
const firstSessionId = createdRuns[0]?.sessionId ?? null;
if (sessionIds.length === 0) {
set({ error: 'Failed to create any sessions', isLoading: false });
return null;
}
const filesForMessage = files?.map((f) => ({
type: 'file' as const,
mime: f.mime,
filename: f.filename,
url: f.url,
}));
void (async () => {
try {
const expandText = useSnippetsStore.getState().expandText;
await Promise.allSettled(
createdRuns.map(async (run) => {
try {
const text = await expandText(run.prompt).catch(() => run.prompt);
await routeMessage({
sessionId: run.sessionId,
directory: run.worktreePath,
content: text,
providerID: run.providerID,
modelID: run.modelID,
variant: run.variant,
agent,
files: filesForMessage,
});
} catch (err) {
console.warn('[MultiRun] Failed to start run:', err);
}
}),
);
} catch (err) {
console.warn('[MultiRun] Failed to start runs:', err);
}
})();
set({ isLoading: false });
return { groupSlug, sessionIds, firstSessionId };
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create Multi-Run',
isLoading: false,
});
return null;
}
},
clearError: () => {
set({ error: null });
},
}),
{ name: 'multirun-store' },
),
);