Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Agents Hub `Installed` and `Discover` tabs should resolve agent avatars with the
Registry installed-agent listing (`/api/registry/pods/:podId/agents`) should prefer matching template `iconUrl` by `(agentName + displayName)` before falling back to registry icon.
Pod browse page (`/pods/:type`) should prioritize pre-entry UX: quick filters (`All`, `Joined`, `Discover`), preview-before-join action, and responsive controls that stay usable on phones.
Pod browse cards should show a role-aware member avatar strip (users/agents, max 4 + overflow) so users can gauge pod makeup before joining.
Project pods (`type="project"`) use a dedicated two-tab workspace (`Chat`, `Tasks`) rather than the generic pod shell; keep the project header prominent, keep the brief/key info in the right sidebar on chat, and treat task state as structured project data rather than a kanban board.
Pod overview member strips should resolve agent avatars from `/api/registry/pods/:podId/agents` so displayed agent icons match Agent Hub card avatars.
Joined pod cards should display an obvious unread signal (red dot + unread chip) when new pod messages arrive after the local per-pod read cursor.
Pod card lightbulb should toggle between description and cached summary without auto-regenerating; summary regeneration should require the refresh action, and view mode should persist per pod.
Expand Down
50 changes: 50 additions & 0 deletions backend/__tests__/unit/controllers/podController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,56 @@ describe('podController', () => {
expect(res.json).toHaveBeenCalledWith([]);
});

it('createPod accepts project type and project metadata', async () => {
const savedPod = {
_id: 'project-1',
name: 'Website Relaunch',
type: 'project',
projectMeta: {
goal: 'Ship the new marketing site',
scope: 'Landing page and onboarding',
successCriteria: ['launch', 'handoff'],
status: 'planning',
},
populate: jest.fn().mockResolvedValue(),
};
const save = jest.fn().mockResolvedValue(savedPod);
Pod.mockImplementation((payload) => ({
...payload,
save,
}));
AgentRegistry.findOne.mockResolvedValue(null);

const req = {
body: {
name: 'Website Relaunch',
description: 'Project pod',
type: 'project',
projectMeta: {
goal: 'Ship the new marketing site',
scope: 'Landing page and onboarding',
successCriteria: ['launch', 'handoff'],
},
},
userId: 'creator',
};
const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };

await podController.createPod(req, res);

expect(Pod).toHaveBeenCalledWith(expect.objectContaining({
type: 'project',
projectMeta: expect.objectContaining({
goal: 'Ship the new marketing site',
scope: 'Landing page and onboarding',
successCriteria: ['launch', 'handoff'],
status: 'planning',
ownerIds: ['creator'],
}),
}));
expect(res.json).toHaveBeenCalledWith(savedPod);
});

it('createPod accepts agent-ensemble type', async () => {
const savedPod = { _id: 'p1', populate: jest.fn().mockResolvedValue() };
const save = jest.fn().mockResolvedValue(savedPod);
Expand Down
25 changes: 25 additions & 0 deletions backend/__tests__/unit/models/Pod.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,29 @@ describe('Pod Model Tests', () => {
mockExternalLinkId.toString(),
);
});

it('should save project metadata on project pods', async () => {
const pod = new Pod({
name: 'Project Pod',
description: 'Work workspace',
type: 'project',
createdBy: testUser._id,
projectMeta: {
goal: 'Launch v1',
scope: 'Frontend and backend',
successCriteria: ['launch checklist complete'],
status: 'planning',
keyLinks: [{ label: 'Figma', url: 'https://example.com/figma' }],
},
});

const savedPod = await pod.save();

expect(savedPod.type).toBe('project');
expect(savedPod.projectMeta.goal).toBe('Launch v1');
expect(savedPod.projectMeta.scope).toBe('Frontend and backend');
expect(savedPod.projectMeta.successCriteria).toEqual(['launch checklist complete']);
expect(savedPod.projectMeta.status).toBe('planning');
expect(savedPod.projectMeta.keyLinks).toHaveLength(1);
});
});
85 changes: 83 additions & 2 deletions backend/controllers/podController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ if (process.env.PG_HOST) {
PGMessage = require('../models/pg/Message');
}

const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'];
const VALID_POD_TYPES = ['chat', 'study', 'games', 'project', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'];
const DEFAULT_POD_AGENT = process.env.DEFAULT_POD_AGENT_NAME || 'commonly-bot';
const DEFAULT_POD_AGENT_SCOPES = [
'context:read',
Expand Down Expand Up @@ -242,7 +242,7 @@ exports.getPodById = async (req: any, res: any) => {
exports.createPod = async (req: any, res: any) => {
try {
const {
name, description, type, joinPolicy, parentPod,
name, description, type, joinPolicy, parentPod, projectMeta,
} = req.body;

if (!name || !type) {
Expand All @@ -258,6 +258,19 @@ exports.createPod = async (req: any, res: any) => {
description,
type,
joinPolicy: joinPolicy === 'invite-only' ? 'invite-only' : 'open',
projectMeta: type === 'project'
? {
goal: projectMeta?.goal || description || '',
scope: projectMeta?.scope || '',
successCriteria: Array.isArray(projectMeta?.successCriteria) ? projectMeta.successCriteria : [],
status: projectMeta?.status || 'planning',
dueDate: projectMeta?.dueDate || null,
ownerIds: Array.isArray(projectMeta?.ownerIds) && projectMeta.ownerIds.length
? projectMeta.ownerIds
: [req.userId],
keyLinks: Array.isArray(projectMeta?.keyLinks) ? projectMeta.keyLinks : [],
}
: undefined,
parentPod: parentPod || null,
createdBy: req.userId,
members: [req.userId],
Expand Down Expand Up @@ -307,6 +320,74 @@ exports.createPod = async (req: any, res: any) => {
}
};

exports.updatePod = async (req: any, res: any) => {
try {
const { id } = req.params;
const {
name, description, joinPolicy, parentPod, projectMeta,
} = req.body || {};

const pod = await Pod.findById(id);
if (!pod) {
return res.status(404).json({ msg: 'Pod not found' });
}

const requesterId = req.userId || req.user?.id || req.user?._id;
const isCreator = pod.createdBy.toString() === requesterId?.toString();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The schema introduces projectMeta.ownerIds (plural) but the auth check here only honors the original createdBy. A co-owner added via ownerIds gets 403 when they try to edit project metadata — they paid for the field but can't use it.

Either:

const isOwner = (pod.projectMeta?.ownerIds || []).some(
  (id) => id.toString() === requesterId?.toString()
);
if (!isCreator && !isOwner && !isGlobalAdmin) { ... }

or drop ownerIds from the schema until co-ownership is actually wired.


Generated by Claude Code

const isGlobalAdmin = await isGlobalAdminRequest(req);
if (!isCreator && !isGlobalAdmin) {
return res.status(403).json({ msg: 'Not authorized to update this pod' });
}

if (typeof name === 'string') pod.name = name.trim();
if (typeof description === 'string') pod.description = description.trim();
if (joinPolicy === 'invite-only' || joinPolicy === 'open') pod.joinPolicy = joinPolicy;
if (parentPod !== undefined) pod.parentPod = parentPod || null;

if (pod.type === 'project' && projectMeta && typeof projectMeta === 'object') {
if (typeof projectMeta.goal === 'string') pod.projectMeta.goal = projectMeta.goal.trim();
if (typeof projectMeta.scope === 'string') pod.projectMeta.scope = projectMeta.scope.trim();
if (Array.isArray(projectMeta.successCriteria)) {
pod.projectMeta.successCriteria = projectMeta.successCriteria
.map((value: unknown) => String(value || '').trim())
.filter(Boolean);
}
if (['planning', 'on-track', 'at-risk', 'blocked', 'complete'].includes(String(projectMeta.status))) {
pod.projectMeta.status = String(projectMeta.status);
}
if (projectMeta.dueDate === null || projectMeta.dueDate === '') {
pod.projectMeta.dueDate = null;
} else if (projectMeta.dueDate) {
pod.projectMeta.dueDate = new Date(projectMeta.dueDate);
}
if (Array.isArray(projectMeta.ownerIds)) {
pod.projectMeta.ownerIds = projectMeta.ownerIds;
}
if (Array.isArray(projectMeta.keyLinks)) {
pod.projectMeta.keyLinks = projectMeta.keyLinks
.map((link: any) => ({
label: String(link?.label || '').trim(),
url: String(link?.url || '').trim(),
}))
.filter((link: any) => link.label || link.url);
}
}

pod.updatedAt = Date.now();
await pod.save();
await pod.populate('createdBy', 'username profilePicture');
await pod.populate('members', 'username profilePicture');

return res.json(pod);
} catch (err: any) {
console.error('Error updating pod:', err.message);
if (err.kind === 'ObjectId') {
return res.status(404).json({ msg: 'Pod not found' });
}
return res.status(500).json({ msg: 'Server Error' });
}
};

// Join a pod
exports.joinPod = async (req: any, res: any) => {
try {
Expand Down
35 changes: 33 additions & 2 deletions backend/models/Pod.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import mongoose, { Document, Schema, Types } from 'mongoose';

export type PodType = 'chat' | 'study' | 'games' | 'agent-ensemble' | 'agent-admin' | 'agent-room' | 'team';
export type PodType = 'chat' | 'study' | 'games' | 'project' | 'agent-ensemble' | 'agent-admin' | 'agent-room' | 'team';
export type PodJoinPolicy = 'open' | 'invite-only';
export type EnsembleParticipantRole = 'starter' | 'responder' | 'synthesizer' | 'observer';
export type HumanParticipation = 'none' | 'read-only' | 'participate';
export type ProjectStatus = 'planning' | 'on-track' | 'at-risk' | 'blocked' | 'complete';

export interface IEnsembleParticipant {
agentType: string;
Expand All @@ -16,6 +17,18 @@ export interface IPod extends Document {
description?: string;
type: PodType;
joinPolicy: PodJoinPolicy;
projectMeta: {
goal?: string;
scope?: string;
successCriteria: string[];
status: ProjectStatus;
dueDate?: Date | null;
ownerIds: Types.ObjectId[];
keyLinks: Array<{
label: string;
url: string;
}>;
};
parentPod?: Types.ObjectId | null;
agentEnsemble: {
enabled: boolean;
Expand Down Expand Up @@ -48,14 +61,32 @@ const PodSchema = new Schema<IPod>(
description: { type: String, trim: true },
type: {
type: String,
enum: ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'],
enum: ['chat', 'study', 'games', 'project', 'agent-ensemble', 'agent-admin', 'agent-room', 'team'],
default: 'chat',
},
joinPolicy: {
type: String,
enum: ['open', 'invite-only'],
default: 'open',
},
projectMeta: {
goal: { type: String, trim: true, default: '' },
scope: { type: String, trim: true, default: '' },
successCriteria: { type: [String], default: [] },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

projectMeta has field-level defaults but no conditional on type === 'project', so every pod (chat/study/games/dm/…) gets a populated projectMeta: { goal:'', scope:'', successCriteria:[], status:'planning', dueDate:null, ownerIds:[], keyLinks:[] } persisted.

Two small wins: save storage on non-project pods, and make the "is this a project?" check a simple field-presence test. Either mark the subdoc default: undefined and set it explicitly in the project branch of createPod, or gate with required: function() { return this.type === 'project'; }.


Generated by Claude Code

status: {
type: String,
enum: ['planning', 'on-track', 'at-risk', 'blocked', 'complete'],
default: 'planning',
},
dueDate: { type: Date, default: null },
ownerIds: [{ type: Schema.Types.ObjectId, ref: 'User' }],
keyLinks: [
{
label: { type: String, trim: true, default: '' },
url: { type: String, trim: true, default: '' },
},
],
},
parentPod: { type: Schema.Types.ObjectId, ref: 'Pod', default: null },
agentEnsemble: {
enabled: { type: Boolean, default: false },
Expand Down
49 changes: 49 additions & 0 deletions backend/models/Task.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import mongoose, { Document, Model, Schema, Types } from 'mongoose';

export type TaskStatus = 'pending' | 'claimed' | 'done' | 'blocked';
export type TaskUpdateKind = 'note' | 'progress' | 'blocker' | 'handoff' | 'decision' | 'completion';

export interface ITaskUpdate {
text: string;
author: string;
authorId?: string | null;
kind?: TaskUpdateKind;
progressPercent?: number | null;
nextStep?: string | null;
createdAt: Date;
}

Expand All @@ -14,11 +18,17 @@ export interface ITask extends Document {
taskNum: number;
taskId: string;
title: string;
description?: string | null;
assignee?: string | null;
assigneeType?: 'human' | 'agent' | null;
assigneeRef?: string | null;
dep?: string | null;
depMockOk: boolean;
parentTask?: string | null;
status: TaskStatus;
priority?: 'low' | 'medium' | 'high' | null;
dueDate?: Date | null;
progressPercent?: number | null;
claimedBy?: string | null;
claimedAt?: Date | null;
completedAt?: Date | null;
Expand All @@ -28,6 +38,15 @@ export interface ITask extends Document {
sourceRef?: string;
githubIssueNumber?: number | null;
githubIssueUrl?: string | null;
blocker?: {
open: boolean;
reason?: string | null;
waitingOn?: string | null;
severity?: 'low' | 'medium' | 'high' | null;
openedAt?: Date | null;
openedBy?: string | null;
resolvedAt?: Date | null;
};
updates: ITaskUpdate[];
createdAt: Date;
updatedAt: Date;
Expand All @@ -39,7 +58,10 @@ const TaskSchema = new Schema<ITask>(
taskNum: { type: Number, required: true },
taskId: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, default: null },
assignee: { type: String, default: null },
assigneeType: { type: String, enum: ['human', 'agent'], default: null },
assigneeRef: { type: String, default: null },
dep: { type: String, default: null },
depMockOk: { type: Boolean, default: false },
parentTask: { type: String, default: null },
Expand All @@ -48,6 +70,13 @@ const TaskSchema = new Schema<ITask>(
enum: ['pending', 'claimed', 'done', 'blocked'],
default: 'pending',
},
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium',
},
dueDate: { type: Date, default: null },
progressPercent: { type: Number, default: 0, min: 0, max: 100 },
claimedBy: { type: String, default: null },
claimedAt: { type: Date, default: null },
completedAt: { type: Date, default: null },
Expand All @@ -57,11 +86,31 @@ const TaskSchema = new Schema<ITask>(
sourceRef: { type: String },
githubIssueNumber: { type: Number, default: null },
githubIssueUrl: { type: String, default: null },
blocker: {
open: { type: Boolean, default: false },
reason: { type: String, default: null },
waitingOn: { type: String, default: null },
severity: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium',
},
openedAt: { type: Date, default: null },
openedBy: { type: String, default: null },
resolvedAt: { type: Date, default: null },
},
updates: [
{
text: { type: String, required: true },
author: { type: String, required: true },
authorId: { type: String, default: null },
kind: {
type: String,
enum: ['note', 'progress', 'blocker', 'handoff', 'decision', 'completion'],
default: 'note',
},
progressPercent: { type: Number, default: null, min: 0, max: 100 },
nextStep: { type: String, default: null },
createdAt: { type: Date, default: Date.now },
},
],
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1676,7 +1676,7 @@ router.post('/pods', agentRuntimeAuth, async (req: any, res: any) => {
return res.status(400).json({ message: 'name and type are required' });
}

const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin'];
const VALID_POD_TYPES = ['chat', 'project', 'study', 'games', 'agent-ensemble', 'agent-admin'];
if (!VALID_POD_TYPES.includes(type)) {
return res.status(400).json({ message: `Invalid pod type. Must be one of: ${VALID_POD_TYPES.join(', ')}` });
}
Expand Down
Loading
Loading