share-resolver.ts
1 import type { ShareEntityType, ShareLink } from './share-link-repository' 2 import { loadStoredItem } from '@/lib/server/storage' 3 import { listMissionReports } from '@/lib/server/missions/mission-repository' 4 5 export interface SharedMissionPayload { 6 kind: 'mission' 7 id: string 8 title: string 9 goal: string 10 successCriteria: string[] 11 status: string 12 createdAt: number 13 milestones: Array<{ at: number; note: string; kind: string }> 14 reports: Array<{ at: number; format: string; content: string }> 15 } 16 17 export interface SharedSkillPayload { 18 kind: 'skill' 19 id: string 20 name: string 21 description: string 22 tags: string[] 23 content: string 24 sourceFormat: string | null 25 createdAt: number | null 26 } 27 28 export interface SharedSessionPayload { 29 kind: 'session' 30 id: string 31 name: string 32 agentName: string | null 33 messages: Array<{ role: string; text: string; at: number | null }> 34 createdAt: number 35 } 36 37 export type SharedPayload = SharedMissionPayload | SharedSkillPayload | SharedSessionPayload 38 39 const MAX_MESSAGES = 60 40 const MAX_MILESTONES = 40 41 const MAX_REPORTS = 10 42 43 export function resolveSharedEntity(link: ShareLink): SharedPayload | null { 44 switch (link.entityType) { 45 case 'mission': 46 return resolveMission(link.entityId) 47 case 'skill': 48 return resolveSkill(link.entityId) 49 case 'session': 50 return resolveSession(link.entityId) 51 default: 52 return null 53 } 54 } 55 56 function resolveMission(id: string): SharedMissionPayload | null { 57 const raw = loadStoredItem('agent_missions', id) as Record<string, unknown> | null 58 if (!raw) return null 59 const milestonesRaw = Array.isArray(raw.milestones) ? raw.milestones : [] 60 const milestones = milestonesRaw 61 .slice(-MAX_MILESTONES) 62 .map((m) => { 63 const entry = (m || {}) as Record<string, unknown> 64 return { 65 at: typeof entry.at === 'number' ? entry.at : 0, 66 note: typeof entry.note === 'string' ? entry.note : '', 67 kind: typeof entry.kind === 'string' ? entry.kind : 'note', 68 } 69 }) 70 71 let reports: SharedMissionPayload['reports'] = [] 72 try { 73 const rows = listMissionReports(id, MAX_REPORTS) 74 reports = rows.map((r) => ({ 75 at: r.generatedAt, 76 format: String(r.format), 77 content: r.body, 78 })) 79 } catch { 80 reports = [] 81 } 82 83 return { 84 kind: 'mission', 85 id, 86 title: typeof raw.title === 'string' ? raw.title : 'Untitled Mission', 87 goal: typeof raw.goal === 'string' ? raw.goal : '', 88 successCriteria: Array.isArray(raw.successCriteria) 89 ? (raw.successCriteria as unknown[]).filter((x): x is string => typeof x === 'string') 90 : [], 91 status: typeof raw.status === 'string' ? raw.status : 'unknown', 92 createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0, 93 milestones, 94 reports, 95 } 96 } 97 98 function resolveSkill(id: string): SharedSkillPayload | null { 99 const raw = loadStoredItem('skills', id) as Record<string, unknown> | null 100 if (!raw) return null 101 return { 102 kind: 'skill', 103 id, 104 name: typeof raw.name === 'string' ? raw.name : 'Unnamed Skill', 105 description: typeof raw.description === 'string' ? raw.description : '', 106 tags: Array.isArray(raw.tags) 107 ? (raw.tags as unknown[]).filter((x): x is string => typeof x === 'string') 108 : [], 109 content: typeof raw.content === 'string' ? raw.content : '', 110 sourceFormat: typeof raw.sourceFormat === 'string' ? raw.sourceFormat : null, 111 createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : null, 112 } 113 } 114 115 function resolveSession(id: string): SharedSessionPayload | null { 116 const raw = loadStoredItem('sessions', id) as Record<string, unknown> | null 117 if (!raw) return null 118 const messagesRaw = Array.isArray(raw.messages) ? raw.messages : [] 119 const messages = messagesRaw.slice(-MAX_MESSAGES).map((m) => { 120 const entry = (m || {}) as Record<string, unknown> 121 return { 122 role: typeof entry.role === 'string' ? entry.role : 'unknown', 123 text: typeof entry.content === 'string' ? entry.content : '', 124 at: typeof entry.at === 'number' ? entry.at : null, 125 } 126 }) 127 128 let agentName: string | null = null 129 const agentId = typeof raw.agentId === 'string' ? raw.agentId : null 130 if (agentId) { 131 const agent = loadStoredItem('agents', agentId) as Record<string, unknown> | null 132 if (agent && typeof agent.name === 'string') agentName = agent.name 133 } 134 135 return { 136 kind: 'session', 137 id, 138 name: typeof raw.name === 'string' ? raw.name : 'Untitled Session', 139 agentName, 140 messages, 141 createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0, 142 } 143 } 144 145 /** 146 * Shape enforced on every outbound shared payload: fields that should never 147 * leak off-instance. Reasons kept on the function to keep the allowlist obvious. 148 */ 149 export const SHARE_ALLOWED_ENTITY_TYPES: readonly ShareEntityType[] = [ 150 'mission', 151 'skill', 152 'session', 153 ] as const