11import { Hono } from 'hono' ;
22import { HTTPException } from 'hono/http-exception' ;
33import { 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' ;
77import {
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' ;
1213import { eventBus } from '../lib/events.js' ;
@@ -17,6 +18,7 @@ import { embed } from '../services/embeddings.js';
1718import { persistTaskSubmission } from '../services/storage.js' ;
1819import { fetchOrderedRowsByIds , searchTasksIndex } from '../services/search.js' ;
1920import { createTaskWithOptionalFunding } from '../services/task-creation.js' ;
21+ import { findSkillMatchedAgents , createSystemMatchInvitations } from '../services/invitation-matching.js' ;
2022
2123const 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