Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export type { TaskState, TaskStoreDeps, PendingNewTask } from './stores/task-sto
export { createRoomStore } from './stores/room-store.js';
export type { RoomState, RoomStoreDeps, PerformerAgent } from './stores/room-store.js';

export { createTeamStore } from './stores/team-store.js';
export type { TeamState, TeamStoreDeps } from './stores/team-store.js';

export { createSystemSessionStore } from './stores/system-session-store.js';
export type { SystemSessionState, SystemSessionMessage, SystemSessionStatus } from './stores/system-session-store.js';

Expand Down
144 changes: 144 additions & 0 deletions packages/core/src/stores/team-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { createStore } from 'zustand/vanilla';
import type { Team, TeamAgent, IpcResult } from '@clawwork/shared';

export interface TeamStoreDeps {
listTeams: () => Promise<IpcResult<Team[]>>;
getTeam: (id: string) => Promise<IpcResult<Team | null>>;
persistTeam: (team: {
id: string;
name: string;
emoji?: string;
description?: string;
gatewayId: string;
source?: string;
version?: string;
agents: Array<{ agentId: string; role?: string; isManager?: boolean }>;
createdAt: string;
updatedAt: string;
}) => Promise<IpcResult>;
deleteTeam: (id: string) => Promise<IpcResult>;
}

export interface TeamState {
teams: Record<string, Team>;
loading: boolean;
loadTeams(): Promise<void>;
createTeam(params: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<string>;
updateTeam(id: string, updates: Partial<Team>): Promise<void>;
deleteTeam(id: string): Promise<void>;
addAgentToTeam(teamId: string, agent: TeamAgent): Promise<void>;
removeAgentFromTeam(teamId: string, agentId: string): Promise<void>;
setManager(teamId: string, agentId: string, isManager: boolean): Promise<void>;
}

export function createTeamStore(deps: TeamStoreDeps) {
const store = createStore<TeamState>((set, get) => ({
teams: {},
loading: false,

loadTeams: async () => {
set({ loading: true });
try {
const res = await deps.listTeams();
if (res.ok && res.result) {
const map: Record<string, Team> = {};
for (const t of res.result) {
map[t.id] = t;
}
set({ teams: map });
}
} catch (err) {
console.error('[team-store] loadTeams failed:', err);
} finally {
set({ loading: false });
}
},

createTeam: async (params) => {
const now = new Date().toISOString();
const id = crypto.randomUUID();
const team: Team = {
id,
...params,
createdAt: now,
updatedAt: now,
};
set((s) => ({ teams: { ...s.teams, [id]: team } }));
const res = await deps.persistTeam({
id: team.id,
name: team.name,
emoji: team.emoji,
description: team.description,
gatewayId: team.gatewayId,
source: team.source,
version: team.version,
agents: team.agents,
createdAt: team.createdAt,
updatedAt: team.updatedAt,
});
if (!res.ok) {
console.error('[team-store] persistTeam failed:', res.error);
}
return id;
},
Comment on lines +57 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The createTeam action performs an optimistic update by adding the new team to the store before the persistence call completes. However, it lacks a rollback mechanism if deps.persistTeam fails. This can lead to the UI showing data that was never actually saved to the database.

References
  1. Ensure state consistency by handling failures in asynchronous persistence calls, especially when using optimistic updates.


updateTeam: async (id, updates) => {
const existing = get().teams[id];
if (!existing) return;
const now = new Date().toISOString();
const updated: Team = { ...existing, ...updates, updatedAt: now };
set((s) => ({ teams: { ...s.teams, [id]: updated } }));
const res = await deps.persistTeam({
id: updated.id,
name: updated.name,
emoji: updated.emoji,
description: updated.description,
gatewayId: updated.gatewayId,
source: updated.source,
version: updated.version,
agents: updated.agents,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
});
if (!res.ok) {
console.error('[team-store] updateTeam persist failed:', res.error);
}
},
Comment on lines +85 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to createTeam, the updateTeam action performs an optimistic update without a rollback strategy. If the database persistence fails, the store will remain in an inconsistent state relative to the actual data on disk.


deleteTeam: async (id) => {
set((s) => {
const next = { ...s.teams };
delete next[id];
return { teams: next };
});
const res = await deps.deleteTeam(id);
if (!res.ok) {
console.error('[team-store] deleteTeam failed:', res.error);
}
},
Comment on lines +108 to +118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The deleteTeam action removes the team from the store optimistically. If deps.deleteTeam fails, the team should be restored to the store to maintain consistency.


addAgentToTeam: async (teamId, agent) => {
const existing = get().teams[teamId];
if (!existing) return;
if (existing.agents.some((a) => a.agentId === agent.agentId)) return;
const updated: Team = { ...existing, agents: [...existing.agents, agent] };
await get().updateTeam(teamId, { agents: updated.agents });
},

removeAgentFromTeam: async (teamId, agentId) => {
const existing = get().teams[teamId];
if (!existing) return;
const updated: Team = { ...existing, agents: existing.agents.filter((a) => a.agentId !== agentId) };
await get().updateTeam(teamId, { agents: updated.agents });
},

setManager: async (teamId, agentId, isManager) => {
const existing = get().teams[teamId];
if (!existing) return;
const agents = existing.agents.map((a) => (a.agentId === agentId ? { ...a, isManager } : a));
await get().updateTeam(teamId, { agents });
},
}));

return store;
}
24 changes: 24 additions & 0 deletions packages/desktop/src/main/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,30 @@ function openDatabaseAt(workspacePath: string): void {
sqlite.exec("ALTER TABLE artifacts ADD COLUMN content_text TEXT NOT NULL DEFAULT ''");
} catch {}

sqlite.exec(`
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT DEFAULT '',
description TEXT DEFAULT '',
gateway_id TEXT NOT NULL,
source TEXT DEFAULT 'local',
version TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);

sqlite.exec(`
CREATE TABLE IF NOT EXISTS team_agents (
team_id TEXT NOT NULL REFERENCES teams(id),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The foreign key constraint for team_id in the team_agents table is missing an ON DELETE CASCADE clause. While the IPC handler manually deletes related agents, enforcing this at the database level ensures data integrity and prevents orphaned records if deletions occur through other paths (e.g., raw SQL execution or future refactors).

References
  1. Maintain database integrity by using appropriate foreign key constraints and cascade behaviors. (link)

agent_id TEXT NOT NULL,
role TEXT DEFAULT '',
is_manager INTEGER DEFAULT 0,
PRIMARY KEY (team_id, agent_id)
)
`);

initFTS(sqlite);

db = drizzle(sqlite, { schema });
Expand Down
27 changes: 26 additions & 1 deletion packages/desktop/src/main/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';

export const tasks = sqliteTable('tasks', {
id: text('id').primaryKey(),
Expand Down Expand Up @@ -54,6 +54,31 @@ export const taskRoomSessions = sqliteTable('task_room_sessions', {
verifiedAt: text('verified_at').notNull(),
});

export const teams = sqliteTable('teams', {
id: text('id').primaryKey(),
name: text('name').notNull(),
emoji: text('emoji').default(''),
description: text('description').default(''),
gatewayId: text('gateway_id').notNull(),
source: text('source').default('local'),
version: text('version').default(''),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});

export const teamAgents = sqliteTable(
'team_agents',
{
teamId: text('team_id')
.notNull()
.references(() => teams.id),
agentId: text('agent_id').notNull(),
role: text('role').default(''),
isManager: integer('is_manager', { mode: 'boolean' }).default(false),
},
(table) => [primaryKey({ columns: [table.teamId, table.agentId] })],
);

export const artifacts = sqliteTable('artifacts', {
id: text('id').primaryKey(),
taskId: text('task_id')
Expand Down
137 changes: 136 additions & 1 deletion packages/desktop/src/main/ipc/data-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ipcMain } from 'electron';
import { eq, desc } from 'drizzle-orm';
import { getDb, isDbReady } from '../db/index.js';
import { tasks, messages, artifacts, taskRooms, taskRoomSessions } from '../db/schema.js';
import { tasks, messages, artifacts, taskRooms, taskRoomSessions, teams, teamAgents } from '../db/schema.js';
import { autoExtractArtifacts } from '../artifact/auto-extract.js';
import { getWorkspacePath } from '../workspace/config.js';

Expand Down Expand Up @@ -343,4 +343,139 @@ export function registerDataHandlers(): void {
return ipcError(err);
}
});

ipcMain.handle('data:teams-list', () => {
if (!isDbReady()) return { ok: true, result: [] };
try {
const db = getDb();
const rows = db.select().from(teams).orderBy(desc(teams.createdAt)).all();
const agentRows = db.select().from(teamAgents).all();
const agentsByTeam = new Map<
string,
Array<{ agentId: string; role: string | null; isManager: boolean | null }>
>();
for (const a of agentRows) {
let list = agentsByTeam.get(a.teamId);
if (!list) {
list = [];
agentsByTeam.set(a.teamId, list);
}
list.push({ agentId: a.agentId, role: a.role, isManager: a.isManager });
}
return {
ok: true,
result: rows.map((r) => ({
...r,
agents: (agentsByTeam.get(r.id) ?? []).map((a) => ({
agentId: a.agentId,
role: a.role ?? '',
isManager: a.isManager ?? false,
})),
})),
};
} catch (err) {
console.error('[data] teams-list failed:', err);
return ipcError(err);
}
});

ipcMain.handle('data:team-get', (_event, params: { id: string }) => {
if (!isDbReady()) return ipcError(new Error('database not ready'));
try {
const db = getDb();
const row = db.select().from(teams).where(eq(teams.id, params.id)).get();
if (!row) return { ok: true, result: null };
const agents = db
.select()
.from(teamAgents)
.where(eq(teamAgents.teamId, params.id))
.all()
.map((a) => ({ agentId: a.agentId, role: a.role ?? '', isManager: a.isManager ?? false }));
return { ok: true, result: { ...row, agents } };
} catch (err) {
console.error('[data] team-get failed:', err);
return ipcError(err);
}
});

ipcMain.handle(
'data:team-persist',
(
_event,
params: {
id: string;
name: string;
emoji?: string;
description?: string;
gatewayId: string;
source?: string;
version?: string;
agents: Array<{ agentId: string; role?: string; isManager?: boolean }>;
createdAt: string;
updatedAt: string;
},
) => {
if (!isDbReady()) return ipcError(new Error('database not ready'));
try {
const db = getDb();
db.transaction((tx) => {
tx.insert(teams)
.values({
id: params.id,
name: params.name,
emoji: params.emoji ?? '',
description: params.description ?? '',
gatewayId: params.gatewayId,
source: params.source ?? 'local',
version: params.version ?? '',
createdAt: params.createdAt,
updatedAt: params.updatedAt,
})
.onConflictDoUpdate({
target: [teams.id],
set: {
name: params.name,
emoji: params.emoji ?? '',
description: params.description ?? '',
gatewayId: params.gatewayId,
source: params.source ?? 'local',
version: params.version ?? '',
updatedAt: params.updatedAt,
},
})
.run();
tx.delete(teamAgents).where(eq(teamAgents.teamId, params.id)).run();
for (const agent of params.agents) {
tx.insert(teamAgents)
.values({
teamId: params.id,
agentId: agent.agentId,
role: agent.role ?? '',
isManager: agent.isManager ?? false,
})
.run();
}
});
return { ok: true };
} catch (err) {
console.error('[data] team-persist failed:', err);
return ipcError(err);
}
},
);

ipcMain.handle('data:team-delete', (_event, params: { id: string }) => {
if (!isDbReady()) return ipcError(new Error('database not ready'));
try {
const db = getDb();
db.transaction((tx) => {
tx.delete(teamAgents).where(eq(teamAgents.teamId, params.id)).run();
tx.delete(teams).where(eq(teams.id, params.id)).run();
});
return { ok: true };
} catch (err) {
console.error('[data] team-delete failed:', err);
return ipcError(err);
}
});
}
Loading
Loading