Skip to content

Commit ec0ab08

Browse files
waydelyleclaude
andcommitted
feat: add private tasks with invitation-based discovery
Private tasks are hidden from public listing and discoverable only via direct agent invitations or automatic skill-based matching. Posters control per-task whether their identity is revealed to workers. - Add visibility/revealIdentity columns to tasks table - Add task_invitations table with unique (taskId, agentId) constraint - Filter private tasks from public GET /api/v1/tasks listing - Add GET /invitations, POST /:id/invite, POST /:id/invitations/decline - Identity masking at response level (404 for unauthorized access) - Skill-based agent matching for private task auto-discovery - SDK: tasks.invitations(), tasks.invite(), tasks.declineInvitation() - Dashboard: private badge, anonymous poster handling, invitations page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7ea2e53 commit ec0ab08

14 files changed

Lines changed: 598 additions & 23 deletions

File tree

packages/api/src/db/schema.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,30 @@ export const tasks = pgTable('tasks', {
114114
descriptionEmbedding: vector('description_embedding', 1536),
115115
qualityScore: real('quality_score'),
116116
qualityDetails: jsonb('quality_details'),
117+
visibility: text('visibility').default('public').notNull(),
118+
revealIdentity: boolean('reveal_identity').default(true).notNull(),
117119
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
118120
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
119121
});
120122

123+
// ============================================
124+
// TASK INVITATIONS
125+
// ============================================
126+
127+
export const taskInvitations = pgTable('task_invitations', {
128+
id: uuid('id').primaryKey().defaultRandom(),
129+
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'cascade' }).notNull(),
130+
agentId: uuid('agent_id').references(() => agents.id).notNull(),
131+
source: text('source').default('direct').notNull(),
132+
status: text('status').default('pending').notNull(),
133+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
134+
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
135+
}, (table) => [
136+
uniqueIndex('task_invitation_unique').on(table.taskId, table.agentId),
137+
index('idx_task_invitations_task_id').on(table.taskId),
138+
index('idx_task_invitations_agent_id').on(table.agentId),
139+
]);
140+
121141
// ============================================
122142
// TASK BIDS
123143
// ============================================

packages/api/src/middleware/auth.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ export const authMiddleware = createMiddleware<AuthContext>(async (c, next) => {
6161
await next();
6262
});
6363

64+
export const optionalAuthMiddleware = createMiddleware<AuthContext>(async (c, next) => {
65+
const authHeader = c.req.header('Authorization');
66+
67+
if (authHeader?.startsWith('Bearer ')) {
68+
try {
69+
const token = authHeader.slice(7);
70+
const payload = await verifyAAT(token);
71+
const active = await isAgentActive(payload.agent_id);
72+
if (active) {
73+
c.set('agent', payload);
74+
}
75+
} catch {
76+
// Invalid token — proceed as unauthenticated
77+
}
78+
}
79+
80+
await next();
81+
});
82+
6483
export function requireScope(scope: Scope) {
6584
return createMiddleware<AuthContext>(async (c, next) => {
6685
const agent = c.get('agent');

packages/api/src/routes/tasks.ts

Lines changed: 226 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Hono } from 'hono';
22
import { HTTPException } from 'hono/http-exception';
33
import { db } from '../db/client.js';
4-
import { tasks, taskBids, agents, disputes } from '../db/schema.js';
5-
import { eq, and, inArray, sql, count, gte, lte, ilike, or, desc } from 'drizzle-orm';
6-
import { authMiddleware, requireScope, type AuthContext } from '../middleware/auth.js';
4+
import { tasks, taskBids, agents, disputes, taskInvitations } from '../db/schema.js';
5+
import { eq, and, ne, inArray, sql, count, gte, lte, ilike, or, desc } from 'drizzle-orm';
6+
import { authMiddleware, optionalAuthMiddleware, requireScope, type AuthContext } from '../middleware/auth.js';
77
import {
88
TaskCreateSchema, TaskUpdateSchema, TaskSubmitSchema, TaskListQuerySchema, TaskDisputeSchema,
9-
TASK_STATUS,
9+
InviteAgentsSchema, InvitationListQuerySchema,
10+
TASK_STATUS, TASK_VISIBILITY, MATCHING_MODE, INVITATION_STATUS, AGENT_STATUS,
1011
DISPUTE_STATUS,
1112
} from '@swarmdock/shared';
1213
import { eventBus } from '../lib/events.js';
@@ -17,6 +18,7 @@ import { embed } from '../services/embeddings.js';
1718
import { persistTaskSubmission } from '../services/storage.js';
1819
import { fetchOrderedRowsByIds, searchTasksIndex } from '../services/search.js';
1920
import { createTaskWithOptionalFunding } from '../services/task-creation.js';
21+
import { findSkillMatchedAgents, createSystemMatchInvitations } from '../services/invitation-matching.js';
2022

2123
const app = new Hono<AuthContext>();
2224

@@ -38,6 +40,7 @@ app.get('/', async (c) => {
3840
assigneeId,
3941
limit,
4042
offset,
43+
visibility: TASK_VISIBILITY.PUBLIC,
4144
});
4245

4346
if (indexed) {
@@ -77,6 +80,7 @@ app.get('/', async (c) => {
7780
}
7881

7982
const conditions = [];
83+
conditions.push(eq(tasks.visibility, TASK_VISIBILITY.PUBLIC));
8084
if (status) conditions.push(eq(tasks.status, status));
8185
if (requesterId) conditions.push(eq(tasks.requesterId, requesterId));
8286
if (assigneeId) conditions.push(eq(tasks.assigneeId, assigneeId));
@@ -161,20 +165,54 @@ app.post('/', authMiddleware, requireScope('tasks.write'), async (c) => {
161165
budgetMax: bigint;
162166
finalPrice: bigint | null;
163167
matchingMode: string;
168+
visibility: string;
169+
revealIdentity: boolean;
164170
};
165171
const directAssigneeId = parsed.data.directAssigneeId ?? null;
172+
const isPrivate = parsed.data.visibility === TASK_VISIBILITY.PRIVATE;
173+
174+
if (isPrivate) {
175+
// Run skill matching for private tasks with open/auto matching
176+
let allInvitedIds = creation.invitedAgentIds ?? [];
177+
if (
178+
parsed.data.skillRequirements.length > 0 &&
179+
(parsed.data.matchingMode === MATCHING_MODE.OPEN || parsed.data.matchingMode === MATCHING_MODE.AUTO)
180+
) {
181+
const excludeIds = [agent.agent_id, ...allInvitedIds];
182+
const matchedIds = await findSkillMatchedAgents(db, parsed.data.skillRequirements, excludeIds);
183+
if (matchedIds.length > 0) {
184+
await createSystemMatchInvitations(db, task.id, matchedIds);
185+
allInvitedIds = [...allInvitedIds, ...matchedIds];
186+
}
187+
}
166188

167-
// Broadcast to all connected agents
168-
eventBus.broadcast({
169-
type: 'task.created',
170-
data: {
189+
// Emit targeted invitations instead of broadcast
190+
const eventData: Record<string, unknown> = {
171191
taskId: task.id,
172192
title: task.title,
173193
skillRequirements: parsed.data.skillRequirements,
174194
budgetMax: parsed.data.budgetMax,
175195
matchingMode: parsed.data.matchingMode,
176-
},
177-
});
196+
};
197+
if (task.revealIdentity) {
198+
eventData.requesterId = agent.agent_id;
199+
}
200+
for (const invitedId of allInvitedIds) {
201+
eventBus.emit(invitedId, { type: 'task.invited', data: eventData });
202+
}
203+
} else {
204+
// Broadcast to all connected agents for public tasks
205+
eventBus.broadcast({
206+
type: 'task.created',
207+
data: {
208+
taskId: task.id,
209+
title: task.title,
210+
skillRequirements: parsed.data.skillRequirements,
211+
budgetMax: parsed.data.budgetMax,
212+
matchingMode: parsed.data.matchingMode,
213+
},
214+
});
215+
}
178216

179217
if (creation.escrow) {
180218
eventBus.emit(agent.agent_id, {
@@ -205,15 +243,181 @@ app.post('/', authMiddleware, requireScope('tasks.write'), async (c) => {
205243
return c.json(task, 201, creation.settlementHeaders);
206244
});
207245

246+
// GET /api/v1/tasks/invitations — List agent's private task invitations
247+
app.get('/invitations', authMiddleware, async (c) => {
248+
const query = InvitationListQuerySchema.safeParse(c.req.query());
249+
if (!query.success) {
250+
return c.json({ error: 'Invalid query', details: query.error.flatten() }, 400);
251+
}
252+
253+
const agent = c.get('agent');
254+
const { status: invStatus, limit, offset } = query.data;
255+
256+
const conditions = [eq(taskInvitations.agentId, agent.agent_id)];
257+
if (invStatus) {
258+
conditions.push(eq(taskInvitations.status, invStatus));
259+
} else {
260+
conditions.push(ne(taskInvitations.status, INVITATION_STATUS.DECLINED));
261+
}
262+
263+
const whereClause = and(...conditions);
264+
265+
const [{ total }] = await db
266+
.select({ total: count() })
267+
.from(taskInvitations)
268+
.where(whereClause);
269+
270+
const rows = await db
271+
.select()
272+
.from(taskInvitations)
273+
.innerJoin(tasks, eq(tasks.id, taskInvitations.taskId))
274+
.where(whereClause)
275+
.limit(limit)
276+
.offset(offset)
277+
.orderBy(desc(taskInvitations.createdAt));
278+
279+
const invitations = rows.map((row) => {
280+
const task = row.tasks;
281+
const invitation = row.task_invitations;
282+
283+
// Identity masking
284+
const isDisputed = task.status === TASK_STATUS.DISPUTED;
285+
const shouldMask = !task.revealIdentity && !isDisputed;
286+
287+
return {
288+
invitation,
289+
task: {
290+
...task,
291+
requesterId: shouldMask ? null : task.requesterId,
292+
},
293+
};
294+
});
295+
296+
return c.json({ invitations, limit, offset, total: Number(total) });
297+
});
298+
299+
// POST /api/v1/tasks/:id/invite — Invite agents to a private task
300+
app.post('/:id/invite', authMiddleware, requireScope('tasks.write'), async (c) => {
301+
const id = c.req.param('id');
302+
const agent = c.get('agent');
303+
304+
const body = await c.req.json();
305+
const parsed = InviteAgentsSchema.safeParse(body);
306+
if (!parsed.success) {
307+
return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
308+
}
309+
310+
const [task] = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
311+
if (!task) return c.json({ error: 'Task not found' }, 404);
312+
if (task.requesterId !== agent.agent_id) return c.json({ error: 'Not task owner' }, 403);
313+
if (task.visibility !== TASK_VISIBILITY.PRIVATE) {
314+
return c.json({ error: 'Can only invite agents to private tasks' }, 400);
315+
}
316+
317+
// Validate agents exist and are active
318+
const validIds = parsed.data.agentIds.filter((agentId) => agentId !== agent.agent_id);
319+
if (validIds.length === 0) {
320+
return c.json({ error: 'No valid agent IDs provided' }, 400);
321+
}
322+
323+
const activeAgents = await db
324+
.select({ id: agents.id })
325+
.from(agents)
326+
.where(and(inArray(agents.id, validIds), eq(agents.status, AGENT_STATUS.ACTIVE)));
327+
328+
const activeIds = activeAgents.map((a) => a.id);
329+
if (activeIds.length === 0) {
330+
return c.json({ error: 'No active agents found for the given IDs' }, 400);
331+
}
332+
333+
// Insert invitations, skipping duplicates
334+
await db.insert(taskInvitations).values(
335+
activeIds.map((agentId) => ({ taskId: id, agentId, source: 'direct' as const })),
336+
).onConflictDoNothing();
337+
338+
// Emit invitation events
339+
const eventData: Record<string, unknown> = {
340+
taskId: task.id,
341+
title: task.title,
342+
skillRequirements: task.skillRequirements,
343+
budgetMax: task.budgetMax.toString(),
344+
matchingMode: task.matchingMode,
345+
};
346+
if (task.revealIdentity) {
347+
eventData.requesterId = agent.agent_id;
348+
}
349+
for (const invitedId of activeIds) {
350+
eventBus.emit(invitedId, { type: 'task.invited', data: eventData });
351+
}
352+
353+
return c.json({ invited: activeIds.length }, 201);
354+
});
355+
356+
// POST /api/v1/tasks/:id/invitations/decline — Decline a task invitation
357+
app.post('/:id/invitations/decline', authMiddleware, async (c) => {
358+
const id = c.req.param('id');
359+
const agent = c.get('agent');
360+
361+
const [invitation] = await db
362+
.select()
363+
.from(taskInvitations)
364+
.where(
365+
and(
366+
eq(taskInvitations.taskId, id),
367+
eq(taskInvitations.agentId, agent.agent_id),
368+
ne(taskInvitations.status, INVITATION_STATUS.DECLINED),
369+
),
370+
)
371+
.limit(1);
372+
373+
if (!invitation) {
374+
return c.json({ error: 'Invitation not found' }, 404);
375+
}
376+
377+
const [updated] = await db
378+
.update(taskInvitations)
379+
.set({ status: INVITATION_STATUS.DECLINED, updatedAt: new Date() })
380+
.where(eq(taskInvitations.id, invitation.id))
381+
.returning();
382+
383+
return c.json(updated);
384+
});
385+
208386
// GET /api/v1/tasks/:id — Task detail
209-
app.get('/:id', async (c) => {
387+
app.get('/:id', optionalAuthMiddleware, async (c) => {
210388
const id = c.req.param('id');
211389
const [task] = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
212390

213391
if (!task) {
214392
return c.json({ error: 'Task not found' }, 404);
215393
}
216394

395+
// Private task access control
396+
if (task.visibility === TASK_VISIBILITY.PRIVATE) {
397+
const agentPayload = c.get('agent');
398+
if (!agentPayload) {
399+
return c.json({ error: 'Task not found' }, 404);
400+
}
401+
const isOwner = task.requesterId === agentPayload.agent_id;
402+
if (!isOwner) {
403+
const [invitation] = await db
404+
.select({ id: taskInvitations.id })
405+
.from(taskInvitations)
406+
.where(
407+
and(
408+
eq(taskInvitations.taskId, id),
409+
eq(taskInvitations.agentId, agentPayload.agent_id),
410+
ne(taskInvitations.status, INVITATION_STATUS.DECLINED),
411+
),
412+
)
413+
.limit(1);
414+
const isAssignee = task.assigneeId === agentPayload.agent_id;
415+
if (!invitation && !isAssignee) {
416+
return c.json({ error: 'Task not found' }, 404);
417+
}
418+
}
419+
}
420+
217421
const bids = await db.select().from(taskBids).where(eq(taskBids.taskId, id));
218422
const [dispute] = await db
219423
.select()
@@ -241,9 +445,19 @@ app.get('/:id', async (c) => {
241445

242446
const agentMap = new Map(agentRows.map((agent) => [agent.id, agent]));
243447

448+
// Identity masking for private tasks
449+
const agentPayload = c.get('agent');
450+
const isOwner = agentPayload?.agent_id === task.requesterId;
451+
const isDisputed = task.status === TASK_STATUS.DISPUTED;
452+
const shouldMaskIdentity = task.visibility === TASK_VISIBILITY.PRIVATE
453+
&& !task.revealIdentity
454+
&& !isOwner
455+
&& !isDisputed;
456+
244457
return c.json({
245458
...task,
246-
requester: agentMap.get(task.requesterId) ?? null,
459+
requesterId: shouldMaskIdentity ? null : task.requesterId,
460+
requester: shouldMaskIdentity ? null : (agentMap.get(task.requesterId) ?? null),
247461
assignee: task.assigneeId ? (agentMap.get(task.assigneeId) ?? null) : null,
248462
bids: bids.map((bid) => ({
249463
...bid,

0 commit comments

Comments
 (0)