From e837eb7db84b5595a773f0840c79ee7aad7a905e Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 21 Mar 2026 12:48:18 +0200 Subject: [PATCH] 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. --- README.md | 1 - .../services/infrastructure/FileWatcher.ts | 6 +- src/main/services/team/TeamConfigReader.ts | 56 +++++++- src/main/services/team/TeamDataService.ts | 26 ++-- src/main/services/team/TeamMetaStore.ts | 124 ++++++++++++++++++ src/main/workers/team-fs-worker.ts | 60 ++++++++- .../components/team/TeamDetailView.tsx | 46 +++++++ src/renderer/components/team/TeamListView.tsx | 50 ++++--- 8 files changed, 338 insertions(+), 31 deletions(-) create mode 100644 src/main/services/team/TeamMetaStore.ts diff --git a/README.md b/README.md index 9aee8d72..8969917f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 8b7d312e..cd03f751 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -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, diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 45e0a29b..a908d795 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -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 { + 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; + 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 { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b8cdce82..8f21767f 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 = { - 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) => ({ diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts new file mode 100644 index 00000000..3cc7aaa6 --- /dev/null +++ b/src/main/services/team/TeamMetaStore.ts @@ -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 { + 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; + 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): Promise { + 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 { + try { + await fs.promises.unlink(this.getMetaPath(teamName)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } +} diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index b5910cbb..266cee1b 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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 | 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; + 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; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index a18862ff..a29059b8 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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 ( + <> +
+
+

Team not launched yet

+

+ {teamSummary?.displayName || teamName} configuration has been saved. Launch to start + provisioning with CLI. +

+
+ + +
+
+
+ setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + ); + } + if (error) { return (
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index e9cfda4f..0bcb1d11 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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 => { )} - - - - - Copy team - + {!team.pendingCreate && ( + + + + + Copy team + + )}