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:
iliya 2026-03-21 12:48:18 +02:00
parent 087a8f4989
commit e837eb7db8
8 changed files with 338 additions and 31 deletions

View file

@ -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

View file

@ -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,

View file

@ -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 {

View file

@ -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) => ({

View 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;
}
}
}
}

View file

@ -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;

View file

@ -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">

View file

@ -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>