feat: implement draft team handling and metadata storage
- Added functionality to read draft team summaries from team.meta.json when config.json is missing. - Introduced methods to save team-level metadata to team.meta.json during team creation. - Updated UI components to handle draft teams, including launch and delete options. - Enhanced error handling for draft teams in various components.
This commit is contained in:
parent
087a8f4989
commit
e837eb7db8
8 changed files with 338 additions and 31 deletions
|
|
@ -238,7 +238,6 @@ pnpm dist # macOS + Windows + Linux
|
|||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||
- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents
|
||||
- [ ] Planning mode to organize agent plans before execution
|
||||
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||
|
|
|
|||
|
|
@ -1003,7 +1003,11 @@ export class FileWatcher extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
if (relative === 'config.json' || relative === 'kanban-state.json') {
|
||||
if (
|
||||
relative === 'config.json' ||
|
||||
relative === 'kanban-state.json' ||
|
||||
relative === 'team.meta.json'
|
||||
) {
|
||||
const event: TeamChangeEvent = {
|
||||
type: 'config',
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import * as path from 'path';
|
|||
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
|
||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||
|
||||
|
|
@ -152,8 +153,8 @@ export class TeamConfigReader {
|
|||
|
||||
// Skip non-regular files (pipes, sockets, etc.) — readFile could hang on them
|
||||
if (!stat?.isFile()) {
|
||||
logger.debug(`Skipping team dir with missing/non-file config: ${teamName}`);
|
||||
return null;
|
||||
// Fallback: check for draft team (team.meta.json without config.json)
|
||||
return this.readDraftTeamSummary(teamsDir, teamName);
|
||||
}
|
||||
|
||||
// Safety: refuse to touch extremely large configs. Even "head" parsing can be misleading,
|
||||
|
|
@ -283,6 +284,57 @@ export class TeamConfigReader {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a draft team (team.meta.json exists without config.json).
|
||||
* This happens when provisioning failed before CLI's TeamCreate could run.
|
||||
*/
|
||||
private async readDraftTeamSummary(
|
||||
teamsDir: string,
|
||||
teamName: string
|
||||
): Promise<TeamSummary | null> {
|
||||
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
||||
try {
|
||||
const metaStat = await fs.promises.stat(metaPath);
|
||||
if (!metaStat.isFile() || metaStat.size > 256 * 1024) {
|
||||
return null;
|
||||
}
|
||||
const metaRaw = await readFileUtf8WithTimeout(metaPath, PER_TEAM_READ_TIMEOUT_MS);
|
||||
const meta = JSON.parse(metaRaw) as Record<string, unknown>;
|
||||
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayName =
|
||||
typeof meta.displayName === 'string' && meta.displayName.trim()
|
||||
? meta.displayName.trim()
|
||||
: teamName;
|
||||
|
||||
let memberCount = 0;
|
||||
try {
|
||||
const metaStore = new TeamMembersMetaStore();
|
||||
const members = await metaStore.getMembers(teamName);
|
||||
memberCount = members.length;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
return {
|
||||
teamName,
|
||||
displayName,
|
||||
description: typeof meta.description === 'string' ? meta.description : '',
|
||||
memberCount,
|
||||
taskCount: 0,
|
||||
lastActivity:
|
||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||
pendingCreate: true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { TeamInboxWriter } from './TeamInboxWriter';
|
|||
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
|
|
@ -113,7 +114,8 @@ export class TeamDataService {
|
|||
teamName,
|
||||
claudeDir: getClaudeBasePath(),
|
||||
}),
|
||||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal()
|
||||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
||||
) {}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
|
|
@ -1611,6 +1613,7 @@ export class TeamDataService {
|
|||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||
const configPath = path.join(teamDir, 'config.json');
|
||||
|
||||
// Check if team already exists (config.json = fully created by CLI)
|
||||
try {
|
||||
await fs.promises.access(configPath, fs.constants.F_OK);
|
||||
throw new Error(`Team already exists: ${request.teamName}`);
|
||||
|
|
@ -1625,17 +1628,18 @@ export class TeamDataService {
|
|||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
const joinedAt = Date.now();
|
||||
const config: Record<string, unknown> = {
|
||||
name: request.displayName?.trim() || request.teamName,
|
||||
description: request.description?.trim() || undefined,
|
||||
color: request.color?.trim() || undefined,
|
||||
};
|
||||
if (request.cwd?.trim()) {
|
||||
config.projectPath = request.cwd.trim();
|
||||
config.projectPathHistory = [request.cwd.trim()];
|
||||
}
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
// Save team-level metadata to team.meta.json (NOT config.json).
|
||||
// config.json is CLI territory — created by TeamCreate during provisioning.
|
||||
// team.meta.json preserves user's configuration for the Launch flow.
|
||||
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||
displayName: request.displayName,
|
||||
description: request.description,
|
||||
color: request.color,
|
||||
cwd: request.cwd?.trim() || '',
|
||||
createdAt: joinedAt,
|
||||
});
|
||||
|
||||
await this.membersMetaStore.writeMembers(
|
||||
request.teamName,
|
||||
request.members.map((member) => ({
|
||||
|
|
|
|||
124
src/main/services/team/TeamMetaStore.ts
Normal file
124
src/main/services/team/TeamMetaStore.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
/**
|
||||
* Persisted team-level metadata saved by the UI before CLI provisioning.
|
||||
* CLI does not know about this file — it only reads/writes config.json.
|
||||
* If provisioning fails before TeamCreate, this file preserves user's
|
||||
* configuration for retry.
|
||||
*/
|
||||
export interface TeamMetaFile {
|
||||
version: 1;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
skipPermissions?: boolean;
|
||||
worktree?: string;
|
||||
extraCliArgs?: string;
|
||||
limitContext?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const MAX_META_FILE_BYTES = 256 * 1024;
|
||||
|
||||
export class TeamMetaStore {
|
||||
private getMetaPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
|
||||
}
|
||||
|
||||
async getMeta(teamName: string): Promise<TeamMetaFile | null> {
|
||||
const metaPath = this.getMetaPath(teamName);
|
||||
try {
|
||||
const stat = await fs.promises.stat(metaPath);
|
||||
if (!stat.isFile() || stat.size > MAX_META_FILE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFileUtf8WithTimeout(metaPath, 5_000);
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
|
||||
error instanceof FileReadTimeoutError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = parsed as Partial<TeamMetaFile>;
|
||||
if (file.version !== 1 || typeof file.cwd !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
displayName:
|
||||
typeof file.displayName === 'string' ? file.displayName.trim() || undefined : undefined,
|
||||
description:
|
||||
typeof file.description === 'string' ? file.description.trim() || undefined : undefined,
|
||||
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
||||
cwd: file.cwd.trim(),
|
||||
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
|
||||
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
|
||||
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
||||
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
||||
worktree: typeof file.worktree === 'string' ? file.worktree.trim() || undefined : undefined,
|
||||
extraCliArgs:
|
||||
typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined,
|
||||
limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined,
|
||||
createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async writeMeta(teamName: string, data: Omit<TeamMetaFile, 'version'>): Promise<void> {
|
||||
const payload: TeamMetaFile = {
|
||||
version: 1,
|
||||
displayName: data.displayName?.trim() || undefined,
|
||||
description: data.description?.trim() || undefined,
|
||||
color: data.color?.trim() || undefined,
|
||||
cwd: data.cwd.trim(),
|
||||
prompt: data.prompt?.trim() || undefined,
|
||||
model: data.model?.trim() || undefined,
|
||||
effort: data.effort?.trim() || undefined,
|
||||
skipPermissions: data.skipPermissions,
|
||||
worktree: data.worktree?.trim() || undefined,
|
||||
extraCliArgs: data.extraCliArgs?.trim() || undefined,
|
||||
limitContext: data.limitContext,
|
||||
createdAt: data.createdAt,
|
||||
};
|
||||
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
async deleteMeta(teamName: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(this.getMetaPath(teamName));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -316,6 +316,57 @@ function dropCliProvisionerMembers(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a draft team summary from team.meta.json when config.json is missing.
|
||||
* Returns null if team.meta.json doesn't exist or is invalid.
|
||||
*/
|
||||
async function readDraftTeamMeta(
|
||||
teamsDir: string,
|
||||
teamName: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const metaPath = path.join(teamsDir, teamName, 'team.meta.json');
|
||||
try {
|
||||
const stat = await fs.promises.stat(metaPath);
|
||||
if (!stat.isFile() || stat.size > 256 * 1024) return null;
|
||||
const raw = await fs.promises.readFile(metaPath, 'utf8');
|
||||
const meta = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (meta?.version !== 1 || typeof meta?.cwd !== 'string') return null;
|
||||
|
||||
const displayName =
|
||||
typeof meta.displayName === 'string' && meta.displayName.trim()
|
||||
? meta.displayName.trim()
|
||||
: teamName;
|
||||
|
||||
// Read members.meta.json for member count
|
||||
let memberCount = 0;
|
||||
try {
|
||||
const membersPath = path.join(teamsDir, teamName, 'members.meta.json');
|
||||
const membersRaw = await fs.promises.readFile(membersPath, 'utf8');
|
||||
const membersData = JSON.parse(membersRaw) as { members?: unknown[] };
|
||||
if (Array.isArray(membersData?.members)) {
|
||||
memberCount = membersData.members.length;
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
return {
|
||||
teamName,
|
||||
displayName,
|
||||
description: typeof meta.description === 'string' ? meta.description : '',
|
||||
memberCount,
|
||||
taskCount: 0,
|
||||
lastActivity:
|
||||
typeof meta.createdAt === 'number' ? new Date(meta.createdAt).toISOString() : null,
|
||||
color: typeof meta.color === 'string' ? meta.color : undefined,
|
||||
projectPath: typeof meta.cwd === 'string' ? meta.cwd : undefined,
|
||||
pendingCreate: true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listTeams(
|
||||
payload: ListTeamsPayload
|
||||
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
|
||||
|
|
@ -358,9 +409,16 @@ async function listTeams(
|
|||
try {
|
||||
stat = await fs.promises.stat(configPath);
|
||||
} catch {
|
||||
// Fallback: check for draft team (team.meta.json without config.json)
|
||||
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
||||
if (draft) return draft;
|
||||
return skip('config_stat_failed');
|
||||
}
|
||||
if (!stat.isFile()) return skip('config_not_file');
|
||||
if (!stat.isFile()) {
|
||||
const draft = await readDraftTeamMeta(payload.teamsDir, teamName);
|
||||
if (draft) return draft;
|
||||
return skip('config_not_file');
|
||||
}
|
||||
if (stat.size > payload.maxConfigBytes) return skip('config_too_large');
|
||||
|
||||
let config: ParsedConfig | null = null;
|
||||
|
|
|
|||
|
|
@ -963,6 +963,52 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
);
|
||||
}
|
||||
|
||||
if (error === 'TEAM_DRAFT') {
|
||||
const teamSummary = teams.find((t) => t.teamName === teamName);
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-full items-center justify-center p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="text-sm font-medium text-text">Team not launched yet</p>
|
||||
<p className="mt-2 text-xs text-text-secondary">
|
||||
{teamSummary?.displayName || teamName} configuration has been saved. Launch to start
|
||||
provisioning with CLI.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => setLaunchDialogOpen(true)}
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text"
|
||||
onClick={() => {
|
||||
void api.teams.deleteDraft(teamName).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LaunchTeamDialog
|
||||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
teamName={teamName}
|
||||
members={[]}
|
||||
defaultProjectPath={teamSummary?.projectPath}
|
||||
provisioningError={provisioningError}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
onClose={() => setLaunchDialogOpen(false)}
|
||||
onLaunch={async (request) => {
|
||||
await launchTeam(request);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center p-6">
|
||||
|
|
|
|||
|
|
@ -407,9 +407,22 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
||||
|
||||
const handleDeleteTeam = useCallback(
|
||||
(teamName: string, e: React.MouseEvent) => {
|
||||
(teamName: string, isDraft: boolean, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void (async () => {
|
||||
if (isDraft) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete draft',
|
||||
message: `Delete draft team "${teamName}"? This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
void api.teams.deleteDraft(teamName).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirm({
|
||||
title: 'Move to trash',
|
||||
message: `Move team "${teamName}" to trash? You can restore it later.`,
|
||||
|
|
@ -529,7 +542,10 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||
setLaunchDialogOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load team data for launch dialog:', err);
|
||||
// Draft teams (no config.json) throw TEAM_DRAFT — expected, use fallback
|
||||
if (!(err instanceof Error && err.message.includes('TEAM_DRAFT'))) {
|
||||
console.error('Failed to load team data for launch dialog:', err);
|
||||
}
|
||||
// Fallback: open dialog with minimal data
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers([]);
|
||||
|
|
@ -840,24 +856,28 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
|
||||
onClick={(e) => handleCopyTeam(team.teamName, e)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Copy team</TooltipContent>
|
||||
</Tooltip>
|
||||
{!team.pendingCreate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
|
||||
onClick={(e) => handleCopyTeam(team.teamName, e)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Copy team</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
|
||||
onClick={(e) => handleDeleteTeam(team.teamName, e)}
|
||||
onClick={(e) =>
|
||||
handleDeleteTeam(team.teamName, !!team.pendingCreate, e)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue