@supaku/agentfactory-nextjs 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/factory.d.ts +105 -0
  3. package/dist/src/factory.d.ts.map +1 -0
  4. package/dist/src/factory.js +89 -0
  5. package/dist/src/handlers/cleanup.d.ts +44 -0
  6. package/dist/src/handlers/cleanup.d.ts.map +1 -0
  7. package/dist/src/handlers/cleanup.js +34 -0
  8. package/dist/src/handlers/public/session-detail.d.ts +31 -0
  9. package/dist/src/handlers/public/session-detail.d.ts.map +1 -0
  10. package/dist/src/handlers/public/session-detail.js +91 -0
  11. package/dist/src/handlers/public/sessions-list.d.ts +20 -0
  12. package/dist/src/handlers/public/sessions-list.d.ts.map +1 -0
  13. package/dist/src/handlers/public/sessions-list.js +73 -0
  14. package/dist/src/handlers/public/stats.d.ts +22 -0
  15. package/dist/src/handlers/public/stats.d.ts.map +1 -0
  16. package/dist/src/handlers/public/stats.js +53 -0
  17. package/dist/src/handlers/sessions/activity.d.ts +15 -0
  18. package/dist/src/handlers/sessions/activity.d.ts.map +1 -0
  19. package/dist/src/handlers/sessions/activity.js +77 -0
  20. package/dist/src/handlers/sessions/claim.d.ts +15 -0
  21. package/dist/src/handlers/sessions/claim.d.ts.map +1 -0
  22. package/dist/src/handlers/sessions/claim.js +87 -0
  23. package/dist/src/handlers/sessions/completion.d.ts +16 -0
  24. package/dist/src/handlers/sessions/completion.d.ts.map +1 -0
  25. package/dist/src/handlers/sessions/completion.js +82 -0
  26. package/dist/src/handlers/sessions/external-urls.d.ts +15 -0
  27. package/dist/src/handlers/sessions/external-urls.d.ts.map +1 -0
  28. package/dist/src/handlers/sessions/external-urls.js +59 -0
  29. package/dist/src/handlers/sessions/get.d.ts +19 -0
  30. package/dist/src/handlers/sessions/get.d.ts.map +1 -0
  31. package/dist/src/handlers/sessions/get.js +46 -0
  32. package/dist/src/handlers/sessions/list.d.ts +26 -0
  33. package/dist/src/handlers/sessions/list.d.ts.map +1 -0
  34. package/dist/src/handlers/sessions/list.js +50 -0
  35. package/dist/src/handlers/sessions/lock-refresh.d.ts +14 -0
  36. package/dist/src/handlers/sessions/lock-refresh.d.ts.map +1 -0
  37. package/dist/src/handlers/sessions/lock-refresh.js +38 -0
  38. package/dist/src/handlers/sessions/progress.d.ts +15 -0
  39. package/dist/src/handlers/sessions/progress.d.ts.map +1 -0
  40. package/dist/src/handlers/sessions/progress.js +82 -0
  41. package/dist/src/handlers/sessions/prompts.d.ts +15 -0
  42. package/dist/src/handlers/sessions/prompts.d.ts.map +1 -0
  43. package/dist/src/handlers/sessions/prompts.js +91 -0
  44. package/dist/src/handlers/sessions/status.d.ts +18 -0
  45. package/dist/src/handlers/sessions/status.d.ts.map +1 -0
  46. package/dist/src/handlers/sessions/status.js +131 -0
  47. package/dist/src/handlers/sessions/tool-error.d.ts +15 -0
  48. package/dist/src/handlers/sessions/tool-error.d.ts.map +1 -0
  49. package/dist/src/handlers/sessions/tool-error.js +91 -0
  50. package/dist/src/handlers/sessions/transfer-ownership.d.ts +14 -0
  51. package/dist/src/handlers/sessions/transfer-ownership.d.ts.map +1 -0
  52. package/dist/src/handlers/sessions/transfer-ownership.js +56 -0
  53. package/dist/src/handlers/workers/get-delete.d.ts +15 -0
  54. package/dist/src/handlers/workers/get-delete.d.ts.map +1 -0
  55. package/dist/src/handlers/workers/get-delete.js +58 -0
  56. package/dist/src/handlers/workers/heartbeat.d.ts +14 -0
  57. package/dist/src/handlers/workers/heartbeat.d.ts.map +1 -0
  58. package/dist/src/handlers/workers/heartbeat.js +42 -0
  59. package/dist/src/handlers/workers/list.d.ts +22 -0
  60. package/dist/src/handlers/workers/list.d.ts.map +1 -0
  61. package/dist/src/handlers/workers/list.js +33 -0
  62. package/dist/src/handlers/workers/poll.d.ts +14 -0
  63. package/dist/src/handlers/workers/poll.d.ts.map +1 -0
  64. package/dist/src/handlers/workers/poll.js +78 -0
  65. package/dist/src/handlers/workers/register.d.ts +9 -0
  66. package/dist/src/handlers/workers/register.d.ts.map +1 -0
  67. package/dist/src/handlers/workers/register.js +41 -0
  68. package/dist/src/index.d.ts +39 -0
  69. package/dist/src/index.d.ts.map +1 -0
  70. package/dist/src/index.js +41 -0
  71. package/dist/src/middleware/cron-auth.d.ts +21 -0
  72. package/dist/src/middleware/cron-auth.d.ts.map +1 -0
  73. package/dist/src/middleware/cron-auth.js +46 -0
  74. package/dist/src/middleware/worker-auth.d.ts +25 -0
  75. package/dist/src/middleware/worker-auth.d.ts.map +1 -0
  76. package/dist/src/middleware/worker-auth.js +43 -0
  77. package/dist/src/types.d.ts +62 -0
  78. package/dist/src/types.d.ts.map +1 -0
  79. package/dist/src/types.js +7 -0
  80. package/dist/src/webhook/handlers/issue-updated.d.ts +14 -0
  81. package/dist/src/webhook/handlers/issue-updated.d.ts.map +1 -0
  82. package/dist/src/webhook/handlers/issue-updated.js +462 -0
  83. package/dist/src/webhook/handlers/session-created.d.ts +9 -0
  84. package/dist/src/webhook/handlers/session-created.d.ts.map +1 -0
  85. package/dist/src/webhook/handlers/session-created.js +229 -0
  86. package/dist/src/webhook/handlers/session-prompted.d.ts +9 -0
  87. package/dist/src/webhook/handlers/session-prompted.d.ts.map +1 -0
  88. package/dist/src/webhook/handlers/session-prompted.js +197 -0
  89. package/dist/src/webhook/handlers/session-updated.d.ts +9 -0
  90. package/dist/src/webhook/handlers/session-updated.d.ts.map +1 -0
  91. package/dist/src/webhook/handlers/session-updated.js +29 -0
  92. package/dist/src/webhook/processor.d.ts +22 -0
  93. package/dist/src/webhook/processor.d.ts.map +1 -0
  94. package/dist/src/webhook/processor.js +98 -0
  95. package/dist/src/webhook/signature.d.ts +16 -0
  96. package/dist/src/webhook/signature.d.ts.map +1 -0
  97. package/dist/src/webhook/signature.js +23 -0
  98. package/dist/src/webhook/utils.d.ts +61 -0
  99. package/dist/src/webhook/utils.d.ts.map +1 -0
  100. package/dist/src/webhook/utils.js +159 -0
  101. package/package.json +66 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Handle agent session 'create' events — new session initiated.
3
+ */
4
+ import { NextResponse } from 'next/server';
5
+ import { TERMINAL_STATUSES, validateWorkTypeForStatus, WORK_TYPE_ALLOWED_STATUSES, STATUS_WORK_TYPE_MAP, getValidWorkTypesForStatus, } from '@supaku/agentfactory-linear';
6
+ import { generateIdempotencyKey, isWebhookProcessed, storeSessionState, getSessionState, updateSessionStatus, dispatchWork, } from '@supaku/agentfactory-server';
7
+ import { emitActivity, determineWorkType, getAppUrl, getPriority, WORK_TYPE_MESSAGES, } from '../utils.js';
8
+ export async function handleSessionCreated(config, payload, rawPayload, log) {
9
+ const agentSession = rawPayload.agentSession;
10
+ log.debug('AgentSessionEvent payload structure', {
11
+ payloadKeys: Object.keys(rawPayload),
12
+ agentSessionKeys: agentSession ? Object.keys(agentSession) : [],
13
+ hasAgentSession: !!agentSession,
14
+ });
15
+ if (!agentSession) {
16
+ log.error('AgentSessionEvent missing agentSession field', {
17
+ payloadKeys: Object.keys(rawPayload),
18
+ });
19
+ return NextResponse.json({ error: 'Missing agentSession in webhook payload', success: false }, { status: 400 });
20
+ }
21
+ const sessionId = agentSession.id;
22
+ const issue = agentSession.issue;
23
+ const issueId = agentSession.issueId || issue?.id;
24
+ const webhookId = rawPayload.webhookId;
25
+ const agentId = agentSession.agentId;
26
+ if (!sessionId || !issueId) {
27
+ log.error('Missing sessionId or issueId in webhook payload', {
28
+ sessionId,
29
+ issueId,
30
+ agentSessionKeys: Object.keys(agentSession),
31
+ });
32
+ return NextResponse.json({ error: 'Invalid payload structure', success: false }, { status: 400 });
33
+ }
34
+ const sessionLog = log.child({ sessionId, issueId });
35
+ const promptContext = rawPayload.promptContext;
36
+ const user = rawPayload.user;
37
+ const comment = rawPayload.comment;
38
+ const isMention = !!comment;
39
+ const initiationType = isMention ? 'mention' : 'delegation';
40
+ sessionLog.info('Agent session created', {
41
+ initiationType,
42
+ isMention,
43
+ hasPromptContext: !!promptContext,
44
+ promptContextLength: promptContext?.length,
45
+ userName: user?.name,
46
+ });
47
+ // Idempotency check
48
+ const idempotencyKey = generateIdempotencyKey(webhookId, sessionId);
49
+ if (await isWebhookProcessed(idempotencyKey)) {
50
+ sessionLog.info('Duplicate webhook ignored', { idempotencyKey });
51
+ return NextResponse.json({ success: true, skipped: true, reason: 'duplicate_webhook' });
52
+ }
53
+ const existingSession = await getSessionState(sessionId);
54
+ if (existingSession) {
55
+ sessionLog.info('Session already exists', { status: existingSession.status });
56
+ return NextResponse.json({ success: true, skipped: true, reason: 'session_already_exists' });
57
+ }
58
+ const issueIdentifier = issue?.identifier || issueId.slice(0, 8);
59
+ // Determine work type
60
+ let workType = 'development';
61
+ let currentStatus;
62
+ let workTypeSource = 'status';
63
+ // Fetch current issue status
64
+ try {
65
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
66
+ const issueDetails = await linearClient.getIssue(issueId);
67
+ const currentState = await issueDetails.state;
68
+ currentStatus = currentState?.name;
69
+ sessionLog.debug('Fetched current issue status', { currentStatus });
70
+ }
71
+ catch (err) {
72
+ sessionLog.warn('Failed to fetch issue status', { error: err });
73
+ }
74
+ // Phase 1: Mention-based routing
75
+ if (isMention && promptContext && config.detectWorkTypeFromPrompt) {
76
+ // For mentions: unconstrained detection (pass all work types)
77
+ const allWorkTypes = [
78
+ 'coordination', 'backlog-creation', 'research', 'qa', 'inflight',
79
+ 'acceptance', 'refinement', 'development', 'qa-coordination', 'acceptance-coordination',
80
+ ];
81
+ const mentionWorkType = config.detectWorkTypeFromPrompt(promptContext, allWorkTypes);
82
+ if (mentionWorkType) {
83
+ workType = mentionWorkType;
84
+ workTypeSource = 'mention';
85
+ sessionLog.info('Detected work type from mention prompt', {
86
+ workType,
87
+ currentStatus,
88
+ promptPreview: promptContext.substring(0, 50),
89
+ });
90
+ }
91
+ }
92
+ // For non-mentions or when mention detection failed: constrained detection
93
+ if (workTypeSource === 'status' && promptContext && currentStatus && config.detectWorkTypeFromPrompt) {
94
+ const validWorkTypes = getValidWorkTypesForStatus(currentStatus);
95
+ const constrainedWorkType = config.detectWorkTypeFromPrompt(promptContext, validWorkTypes);
96
+ if (constrainedWorkType) {
97
+ workType = constrainedWorkType;
98
+ workTypeSource = 'mention';
99
+ sessionLog.info('Detected work type from promptContext (constrained)', {
100
+ workType,
101
+ currentStatus,
102
+ validWorkTypes,
103
+ });
104
+ }
105
+ }
106
+ // Phase 2: Fall back to status-based routing
107
+ if (workTypeSource === 'status') {
108
+ workType = determineWorkType(currentStatus);
109
+ sessionLog.info('Detected work type from issue status', {
110
+ currentStatus,
111
+ workType,
112
+ });
113
+ }
114
+ // Phase 2.5: Auto-detect parent issues for coordination
115
+ if (workType === 'development' && workTypeSource === 'status') {
116
+ try {
117
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
118
+ const isParent = await linearClient.isParentIssue(issueId);
119
+ if (isParent) {
120
+ workType = 'coordination';
121
+ sessionLog.info('Parent issue detected, switching to coordination work type', {
122
+ issueIdentifier,
123
+ });
124
+ }
125
+ }
126
+ catch (err) {
127
+ sessionLog.warn('Failed to check if issue is parent', { error: err });
128
+ }
129
+ }
130
+ // Check terminal state
131
+ if (currentStatus && TERMINAL_STATUSES.includes(currentStatus)) {
132
+ sessionLog.info('Issue in terminal state, acknowledging mention', { currentStatus });
133
+ try {
134
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
135
+ await emitActivity(linearClient, sessionId, 'response', `This issue is in **${currentStatus}** status and has been completed. No further agent work is needed.\n\n` +
136
+ `If you need additional help, please create a new issue or reopen this one by moving it back to an active status.`);
137
+ }
138
+ catch (err) {
139
+ sessionLog.error('Failed to emit terminal state response', { error: err });
140
+ }
141
+ return NextResponse.json({ success: true, skipped: true, reason: 'terminal_state', currentStatus });
142
+ }
143
+ // Validate work type for status
144
+ if (currentStatus) {
145
+ const validation = validateWorkTypeForStatus(workType, currentStatus);
146
+ if (!validation.valid) {
147
+ sessionLog.warn('Work type validation failed', { workType, currentStatus, error: validation.error });
148
+ try {
149
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
150
+ const allowedStatuses = WORK_TYPE_ALLOWED_STATUSES[workType];
151
+ const expectedWorkType = STATUS_WORK_TYPE_MAP[currentStatus];
152
+ await emitActivity(linearClient, sessionId, 'error', `Cannot perform ${workType} work on this issue.\n\n` +
153
+ `**Current status:** ${currentStatus}\n` +
154
+ `**${workType} requires status:** ${allowedStatuses.join(' or ')}\n\n` +
155
+ (expectedWorkType
156
+ ? `For issues in ${currentStatus} status, use ${expectedWorkType} commands instead.`
157
+ : `This issue's status (${currentStatus}) is not handled by the agent.`));
158
+ }
159
+ catch (err) {
160
+ sessionLog.error('Failed to emit validation error activity', { error: err });
161
+ }
162
+ return NextResponse.json({
163
+ success: false,
164
+ error: 'work_type_invalid_for_status',
165
+ message: validation.error,
166
+ });
167
+ }
168
+ }
169
+ const priority = getPriority(config, workType);
170
+ await storeSessionState(sessionId, {
171
+ issueId,
172
+ issueIdentifier,
173
+ claudeSessionId: null,
174
+ worktreePath: '',
175
+ status: 'pending',
176
+ queuedAt: Date.now(),
177
+ promptContext: promptContext,
178
+ priority,
179
+ organizationId: payload.organizationId,
180
+ workType,
181
+ agentId,
182
+ });
183
+ // Queue work
184
+ const work = {
185
+ sessionId,
186
+ issueId,
187
+ issueIdentifier,
188
+ priority,
189
+ queuedAt: Date.now(),
190
+ workType,
191
+ prompt: config.generatePrompt(issueIdentifier, workType, promptContext),
192
+ };
193
+ const result = await dispatchWork(work);
194
+ if (result.dispatched || result.parked) {
195
+ sessionLog.info('Work dispatched', {
196
+ sessionId,
197
+ issueIdentifier,
198
+ workType,
199
+ dispatched: result.dispatched,
200
+ parked: result.parked,
201
+ replaced: result.replaced,
202
+ });
203
+ }
204
+ else {
205
+ sessionLog.error('Failed to dispatch work');
206
+ await updateSessionStatus(sessionId, 'failed');
207
+ }
208
+ // Update session with externalUrl and acknowledge
209
+ try {
210
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
211
+ const appUrl = getAppUrl(config);
212
+ await linearClient.updateAgentSession({
213
+ sessionId,
214
+ externalUrls: [
215
+ {
216
+ label: 'Agent Dashboard',
217
+ url: `${appUrl}/sessions/${sessionId}`,
218
+ },
219
+ ],
220
+ });
221
+ const activityText = WORK_TYPE_MESSAGES[workType];
222
+ await emitActivity(linearClient, sessionId, 'thought', activityText);
223
+ sessionLog.debug('Session updated and activity emitted');
224
+ }
225
+ catch (err) {
226
+ sessionLog.error('Failed to update session or create comment', { error: err });
227
+ }
228
+ return null; // Continue processing
229
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Handle agent session 'prompted' events — follow-up messages or stop/continue signals.
3
+ */
4
+ import { NextResponse } from 'next/server';
5
+ import type { LinearWebhookPayload } from '@supaku/agentfactory-linear';
6
+ import type { WebhookConfig } from '../../types.js';
7
+ import type { createLogger } from '@supaku/agentfactory-server';
8
+ export declare function handleSessionPrompted(config: WebhookConfig, payload: LinearWebhookPayload, rawPayload: Record<string, unknown>, log: ReturnType<typeof createLogger>): Promise<NextResponse | null>;
9
+ //# sourceMappingURL=session-prompted.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-prompted.d.ts","sourceRoot":"","sources":["../../../../src/webhook/handlers/session-prompted.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAiB,MAAM,6BAA6B,CAAA;AAatF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAA;AAE/D,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,oBAAoB,EAC7B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,GAAG,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,GACnC,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAkP9B"}
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Handle agent session 'prompted' events — follow-up messages or stop/continue signals.
3
+ */
4
+ import { NextResponse } from 'next/server';
5
+ import { getSessionState, updateSessionStatus, storeSessionState, releaseClaim, removeWorkerSession, dispatchWork, storePendingPrompt, generateIdempotencyKey, isWebhookProcessed, } from '@supaku/agentfactory-server';
6
+ import { handleStopSignal, emitActivity } from '../utils.js';
7
+ export async function handleSessionPrompted(config, payload, rawPayload, log) {
8
+ const agentSession = rawPayload.agentSession;
9
+ if (!agentSession) {
10
+ log.warn('AgentSessionEvent prompted missing agentSession field');
11
+ return NextResponse.json({ success: true, skipped: true, reason: 'missing_agent_session' });
12
+ }
13
+ const sessionId = agentSession.id;
14
+ const issue = agentSession.issue;
15
+ const issueId = agentSession.issueId || issue?.id;
16
+ const promptText = rawPayload.promptContext || '';
17
+ const webhookId = rawPayload.webhookId;
18
+ const user = rawPayload.user;
19
+ const agentActivity = rawPayload.agentActivity;
20
+ const comment = rawPayload.comment;
21
+ const commentBody = comment?.body || '';
22
+ const promptLog = log.child({ sessionId, issueId });
23
+ // Check for stop/continue signals
24
+ const activitySignal = agentActivity?.signal;
25
+ const isStopSignal = activitySignal === 'stop';
26
+ const isContinueSignal = activitySignal === 'continue';
27
+ promptLog.info('Agent session prompted', {
28
+ hasPromptContext: !!promptText,
29
+ promptContextLength: promptText?.length,
30
+ userName: user?.name,
31
+ hasAgentActivity: !!agentActivity,
32
+ activitySignal,
33
+ isStopSignal,
34
+ isContinueSignal,
35
+ hasComment: !!comment,
36
+ commentBodyLength: commentBody?.length,
37
+ });
38
+ // Handle stop signal
39
+ if (isStopSignal) {
40
+ promptLog.info('Stop signal received via prompted webhook');
41
+ await handleStopSignal(config, sessionId, issueId, payload.organizationId);
42
+ return NextResponse.json({ success: true, action: 'stopped', sessionId });
43
+ }
44
+ // Handle continue signal
45
+ if (isContinueSignal) {
46
+ promptLog.info('Continue signal received via prompted webhook');
47
+ const existingSession = await getSessionState(sessionId);
48
+ const workType = existingSession?.workType || 'inflight';
49
+ let resumePrompt = promptText || commentBody || '';
50
+ const issueIdentifier = existingSession?.issueIdentifier ||
51
+ issue?.identifier ||
52
+ issueId.slice(0, 8);
53
+ promptLog.info('Session state for continue', {
54
+ hasExistingSession: !!existingSession,
55
+ sessionStatus: existingSession?.status,
56
+ workType,
57
+ });
58
+ if (!resumePrompt.trim()) {
59
+ resumePrompt = config.generatePrompt(issueIdentifier, workType);
60
+ }
61
+ // Reset session status if in terminal state
62
+ if (existingSession && ['completed', 'failed', 'stopped'].includes(existingSession.status)) {
63
+ await releaseClaim(sessionId);
64
+ if (existingSession.workerId) {
65
+ await removeWorkerSession(existingSession.workerId, sessionId);
66
+ }
67
+ await updateSessionStatus(sessionId, 'pending');
68
+ }
69
+ // Update organizationId if missing
70
+ if (existingSession && !existingSession.organizationId && payload.organizationId) {
71
+ await storeSessionState(sessionId, {
72
+ ...existingSession,
73
+ organizationId: payload.organizationId,
74
+ status: 'pending',
75
+ });
76
+ }
77
+ // Queue work to resume
78
+ const work = {
79
+ sessionId,
80
+ issueId,
81
+ issueIdentifier,
82
+ priority: 2,
83
+ queuedAt: Date.now(),
84
+ prompt: resumePrompt,
85
+ claudeSessionId: existingSession?.claudeSessionId || undefined,
86
+ workType,
87
+ };
88
+ await dispatchWork(work);
89
+ try {
90
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
91
+ await emitActivity(linearClient, sessionId, 'thought', 'Session resume requested. Waiting for an available worker...');
92
+ }
93
+ catch (err) {
94
+ promptLog.error('Failed to emit continue acknowledgment activity', { error: err });
95
+ }
96
+ return NextResponse.json({ success: true, action: 'continue_queued', sessionId });
97
+ }
98
+ // Generate idempotency key for prompted events
99
+ const idempotencyKey = generateIdempotencyKey(webhookId, `${sessionId}:prompt:${payload.createdAt}`);
100
+ if (await isWebhookProcessed(idempotencyKey)) {
101
+ promptLog.info('Duplicate prompted webhook ignored', { idempotencyKey });
102
+ return NextResponse.json({ success: true, skipped: true, reason: 'duplicate_prompt' });
103
+ }
104
+ const existingSession = await getSessionState(sessionId);
105
+ const issueIdentifier = existingSession?.issueIdentifier ||
106
+ issue?.identifier ||
107
+ issueId.slice(0, 8);
108
+ // Determine effective prompt with cascading fallbacks
109
+ let effectivePrompt = promptText.trim();
110
+ if (!effectivePrompt && commentBody.trim()) {
111
+ effectivePrompt = commentBody.trim();
112
+ promptLog.info('Using comment body as prompt fallback');
113
+ }
114
+ if (!effectivePrompt) {
115
+ if (existingSession) {
116
+ effectivePrompt = config.generatePrompt(issueIdentifier, existingSession.workType || 'inflight');
117
+ promptLog.info('Generated continue prompt for empty prompt');
118
+ }
119
+ else {
120
+ promptLog.warn('Empty prompt with no existing session, skipping');
121
+ return NextResponse.json({ success: true, skipped: true, reason: 'empty_prompt_no_session' });
122
+ }
123
+ }
124
+ // If session is running, store as pending prompt
125
+ if (existingSession?.status === 'running' || existingSession?.status === 'claimed') {
126
+ const userInfo = user ? {
127
+ id: user.id,
128
+ name: user.name,
129
+ } : undefined;
130
+ const pendingPrompt = await storePendingPrompt(sessionId, issueId, effectivePrompt, userInfo);
131
+ if (pendingPrompt) {
132
+ promptLog.info('Pending prompt stored for running session', {
133
+ promptId: pendingPrompt.id,
134
+ sessionId,
135
+ issueIdentifier,
136
+ workerId: existingSession.workerId,
137
+ });
138
+ }
139
+ else {
140
+ promptLog.error('Failed to store pending prompt');
141
+ }
142
+ }
143
+ else {
144
+ // Session not running — queue as work
145
+ promptLog.info('Queuing follow-up for non-running session', {
146
+ sessionStatus: existingSession?.status,
147
+ });
148
+ if (existingSession && ['completed', 'failed', 'stopped'].includes(existingSession.status)) {
149
+ await releaseClaim(sessionId);
150
+ if (existingSession.workerId) {
151
+ await removeWorkerSession(existingSession.workerId, sessionId);
152
+ }
153
+ await updateSessionStatus(sessionId, 'pending');
154
+ }
155
+ if (existingSession && !existingSession.organizationId && payload.organizationId) {
156
+ await storeSessionState(sessionId, {
157
+ ...existingSession,
158
+ organizationId: payload.organizationId,
159
+ status: 'pending',
160
+ });
161
+ }
162
+ const work = {
163
+ sessionId,
164
+ issueId,
165
+ issueIdentifier,
166
+ priority: 2,
167
+ queuedAt: Date.now(),
168
+ prompt: effectivePrompt,
169
+ claudeSessionId: existingSession?.claudeSessionId || undefined,
170
+ workType: existingSession?.workType || 'inflight',
171
+ };
172
+ const dispatchResult = await dispatchWork(work);
173
+ if (dispatchResult.dispatched || dispatchResult.parked) {
174
+ promptLog.info('Follow-up prompt dispatched', {
175
+ sessionId,
176
+ issueIdentifier,
177
+ dispatched: dispatchResult.dispatched,
178
+ parked: dispatchResult.parked,
179
+ });
180
+ }
181
+ else {
182
+ promptLog.error('Failed to dispatch follow-up prompt');
183
+ }
184
+ }
185
+ // Acknowledge prompt receipt
186
+ try {
187
+ const linearClient = await config.linearClient.getClient(payload.organizationId);
188
+ const truncatedPrompt = effectivePrompt.length > 100
189
+ ? `${effectivePrompt.substring(0, 100)}...`
190
+ : effectivePrompt;
191
+ await emitActivity(linearClient, sessionId, 'thought', `Follow-up received: "${truncatedPrompt}" - Processing...`);
192
+ }
193
+ catch (err) {
194
+ promptLog.error('Failed to emit prompt acknowledgment activity', { error: err });
195
+ }
196
+ return null; // Continue processing
197
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Handle agent session 'update' events — stop signal from user.
3
+ */
4
+ import { NextResponse } from 'next/server';
5
+ import type { LinearWebhookPayload } from '@supaku/agentfactory-linear';
6
+ import type { WebhookConfig } from '../../types.js';
7
+ import type { createLogger } from '@supaku/agentfactory-server';
8
+ export declare function handleSessionUpdated(config: WebhookConfig, payload: LinearWebhookPayload, rawPayload: Record<string, unknown>, log: ReturnType<typeof createLogger>): Promise<NextResponse | null>;
9
+ //# sourceMappingURL=session-updated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-updated.d.ts","sourceRoot":"","sources":["../../../../src/webhook/handlers/session-updated.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAA;AAE/D,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,oBAAoB,EAC7B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,GAAG,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,GACnC,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CA6B9B"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Handle agent session 'update' events — stop signal from user.
3
+ */
4
+ import { NextResponse } from 'next/server';
5
+ import { handleStopSignal } from '../utils.js';
6
+ export async function handleSessionUpdated(config, payload, rawPayload, log) {
7
+ const agentSession = rawPayload.agentSession;
8
+ if (!agentSession) {
9
+ log.warn('AgentSessionEvent updated missing agentSession field');
10
+ return NextResponse.json({ success: true, skipped: true, reason: 'missing_agent_session' });
11
+ }
12
+ const sessionId = agentSession.id;
13
+ const issue = agentSession.issue;
14
+ const issueId = agentSession.issueId || issue?.id;
15
+ const newState = agentSession.state;
16
+ const updatedFrom = rawPayload.updatedFrom;
17
+ const previousState = updatedFrom?.state;
18
+ const updateLog = log.child({ sessionId, issueId });
19
+ updateLog.info('Agent session updated', {
20
+ newState,
21
+ previousState,
22
+ });
23
+ // Check if this is a stop signal (state changed to completed/failed)
24
+ if (newState === 'completed' || newState === 'failed') {
25
+ updateLog.info('Stop signal received via updated webhook', { previousState });
26
+ await handleStopSignal(config, sessionId, issueId, payload.organizationId);
27
+ }
28
+ return null; // Continue processing other handlers
29
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Webhook Processor — Main Entry Point
3
+ *
4
+ * Receives Linear webhook events, verifies signatures, runs idempotency
5
+ * checks, and dispatches to sub-handlers.
6
+ */
7
+ import { NextRequest, NextResponse } from 'next/server';
8
+ import type { WebhookConfig } from '../types.js';
9
+ /**
10
+ * Create webhook route handlers from config.
11
+ *
12
+ * Returns { POST, GET } for use as Next.js App Router exports.
13
+ */
14
+ export declare function createWebhookHandler(config: WebhookConfig): {
15
+ POST: (request: NextRequest) => Promise<NextResponse<unknown>>;
16
+ GET: () => Promise<NextResponse<{
17
+ status: string;
18
+ endpoint: string;
19
+ description: string;
20
+ }>>;
21
+ };
22
+ //# sourceMappingURL=processor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processor.d.ts","sourceRoot":"","sources":["../../../src/webhook/processor.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AASvD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAShD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,aAAa;oBAC3B,WAAW;;;;;;EA2FzC"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Webhook Processor — Main Entry Point
3
+ *
4
+ * Receives Linear webhook events, verifies signatures, runs idempotency
5
+ * checks, and dispatches to sub-handlers.
6
+ */
7
+ import { NextResponse } from 'next/server';
8
+ import { isAgentSessionCreated, isAgentSessionPrompted, isAgentSessionUpdated, isIssueUpdate, } from '@supaku/agentfactory-linear';
9
+ import { createLogger, generateRequestId } from '@supaku/agentfactory-server';
10
+ import { verifyWebhookSignature } from './signature.js';
11
+ import { handleSessionCreated } from './handlers/session-created.js';
12
+ import { handleSessionUpdated } from './handlers/session-updated.js';
13
+ import { handleSessionPrompted } from './handlers/session-prompted.js';
14
+ import { handleIssueUpdated } from './handlers/issue-updated.js';
15
+ const baseLogger = createLogger('webhook');
16
+ /**
17
+ * Create webhook route handlers from config.
18
+ *
19
+ * Returns { POST, GET } for use as Next.js App Router exports.
20
+ */
21
+ export function createWebhookHandler(config) {
22
+ async function POST(request) {
23
+ const startTime = Date.now();
24
+ const requestId = generateRequestId();
25
+ const log = baseLogger.child({ requestId });
26
+ try {
27
+ const body = await request.text();
28
+ const signature = request.headers.get('linear-signature');
29
+ // Verify webhook signature
30
+ const webhookSecret = config.webhookSecret ?? process.env.LINEAR_WEBHOOK_SECRET;
31
+ if (!webhookSecret) {
32
+ const isProduction = process.env.NODE_ENV === 'production' ||
33
+ process.env.VERCEL_ENV === 'production';
34
+ if (isProduction) {
35
+ log.error('LINEAR_WEBHOOK_SECRET not configured - rejecting webhook in production');
36
+ return NextResponse.json({ error: 'Service unavailable', message: 'Webhook signature verification not configured' }, { status: 503 });
37
+ }
38
+ else {
39
+ log.warn('LINEAR_WEBHOOK_SECRET not configured - skipping signature verification in development');
40
+ }
41
+ }
42
+ else {
43
+ if (!verifyWebhookSignature(body, signature, webhookSecret)) {
44
+ log.warn('Invalid webhook signature');
45
+ return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
46
+ }
47
+ }
48
+ const payload = JSON.parse(body);
49
+ log.info('Webhook received', {
50
+ webhookType: payload.type,
51
+ webhookAction: payload.action,
52
+ workspaceId: payload.organizationId,
53
+ });
54
+ const rawPayload = payload;
55
+ // Handle agent session 'create' events
56
+ if (isAgentSessionCreated(payload)) {
57
+ const result = await handleSessionCreated(config, payload, rawPayload, log);
58
+ if (result)
59
+ return result;
60
+ }
61
+ // Handle agent session 'update' events (stop signal)
62
+ if (isAgentSessionUpdated(payload)) {
63
+ const result = await handleSessionUpdated(config, payload, rawPayload, log);
64
+ if (result)
65
+ return result;
66
+ }
67
+ // Handle agent session 'prompted' events (follow-up messages)
68
+ if (isAgentSessionPrompted(payload)) {
69
+ const result = await handleSessionPrompted(config, payload, rawPayload, log);
70
+ if (result)
71
+ return result;
72
+ }
73
+ // Handle Issue update events (status transitions)
74
+ if (isIssueUpdate(payload)) {
75
+ const result = await handleIssueUpdated(config, payload, log);
76
+ if (result)
77
+ return result;
78
+ }
79
+ const durationMs = Date.now() - startTime;
80
+ log.info('Webhook processed', { durationMs });
81
+ return NextResponse.json({ success: true, duration: durationMs, requestId });
82
+ }
83
+ catch (err) {
84
+ const durationMs = Date.now() - startTime;
85
+ log.error('Webhook error', { error: err, durationMs });
86
+ return NextResponse.json({ error: 'Internal server error', requestId }, { status: 500 });
87
+ }
88
+ }
89
+ async function GET() {
90
+ baseLogger.debug('Health check requested');
91
+ return NextResponse.json({
92
+ status: 'ok',
93
+ endpoint: '/webhook',
94
+ description: 'Linear webhook receiver',
95
+ });
96
+ }
97
+ return { POST, GET };
98
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Webhook Signature Verification
3
+ *
4
+ * HMAC-SHA256 verification for Linear webhook signatures.
5
+ * @see https://developers.linear.app/docs/webhooks#validating-webhooks
6
+ */
7
+ /**
8
+ * Verify a Linear webhook signature using HMAC-SHA256.
9
+ *
10
+ * @param body - Raw request body string
11
+ * @param signature - Value of the `linear-signature` header
12
+ * @param secret - The webhook signing secret
13
+ * @returns true if the signature is valid
14
+ */
15
+ export declare function verifyWebhookSignature(body: string, signature: string | null, secret: string): boolean;
16
+ //# sourceMappingURL=signature.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signature.d.ts","sourceRoot":"","sources":["../../../src/webhook/signature.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,GACb,OAAO,CAQT"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Webhook Signature Verification
3
+ *
4
+ * HMAC-SHA256 verification for Linear webhook signatures.
5
+ * @see https://developers.linear.app/docs/webhooks#validating-webhooks
6
+ */
7
+ import crypto from 'crypto';
8
+ /**
9
+ * Verify a Linear webhook signature using HMAC-SHA256.
10
+ *
11
+ * @param body - Raw request body string
12
+ * @param signature - Value of the `linear-signature` header
13
+ * @param secret - The webhook signing secret
14
+ * @returns true if the signature is valid
15
+ */
16
+ export function verifyWebhookSignature(body, signature, secret) {
17
+ if (!signature)
18
+ return false;
19
+ const hmac = crypto.createHmac('sha256', secret);
20
+ hmac.update(body);
21
+ const digest = hmac.digest('hex');
22
+ return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
23
+ }