@parall/parel-channel 1.40.0 → 1.41.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.
- package/dist/channel-prompt.d.ts +9 -1
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +17 -0
- package/dist/connect.d.ts +7 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +9 -0
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +38 -10
- package/dist/fork.d.ts +145 -0
- package/dist/fork.d.ts.map +1 -0
- package/dist/fork.js +320 -0
- package/dist/inbound-channel.d.ts +29 -0
- package/dist/inbound-channel.d.ts.map +1 -0
- package/dist/inbound-channel.js +213 -0
- package/dist/inbound.d.ts +14 -0
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +571 -271
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/parel-sdk-compat.d.ts +63 -0
- package/dist/parel-sdk-compat.d.ts.map +1 -0
- package/dist/parel-sdk-compat.js +16 -0
- package/dist/session.d.ts +88 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +225 -11
- package/dist/typed-dispatch.d.ts +76 -0
- package/dist/typed-dispatch.d.ts.map +1 -0
- package/dist/typed-dispatch.js +345 -0
- package/package.json +1 -1
- package/src/channel-prompt.ts +28 -1
- package/src/connect.ts +10 -0
- package/src/delivery.ts +38 -9
- package/src/fork.ts +432 -0
- package/src/inbound-channel.ts +256 -0
- package/src/inbound.ts +634 -303
- package/src/index.ts +7 -1
- package/src/parel-sdk-compat.ts +77 -0
- package/src/session.ts +247 -12
- package/src/typed-dispatch.ts +521 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
3
|
+
import { REQUEST_TIMEOUT_MS } from './session.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Typed (non-message) dispatches → agent events.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the standard runtimes' coverage (agent-core gateway dispatch.new
|
|
9
|
+
* switch; hermes adapter's inline port): task_assign / task_update /
|
|
10
|
+
* task_comment / wiki_comment / schedule.fire / external_trigger /
|
|
11
|
+
* approval_decided. Each handler pulls the source entity, renders the same
|
|
12
|
+
* event-body line format agent-core's buildEventBody produces (so an agent
|
|
13
|
+
* moved between runtimes reads identical events), and returns a plan the
|
|
14
|
+
* inbound pipeline turns into envelope + progress reporting + ack.
|
|
15
|
+
*
|
|
16
|
+
* Semantics carried over from the standard runtimes:
|
|
17
|
+
* - Permanently-unusable sources (404/403, deleted comments, no_reply hints,
|
|
18
|
+
* stale task assignment) resolve to `stale` — the caller acks and drops.
|
|
19
|
+
* - Transient fetch failures resolve to `pending` — the dispatch stays
|
|
20
|
+
* pending and the reconnect catch-up sweep retries it.
|
|
21
|
+
* - schedule.fire tolerates 404 by contract (post-fire PATCH on target_ids /
|
|
22
|
+
* attached_to_uri legitimately revokes access to a delivered run snapshot;
|
|
23
|
+
* docs/primitives/schedule.md § Snapshot 不变性) — warn + ack + drop.
|
|
24
|
+
*
|
|
25
|
+
* Reply semantics differ from chat messages ON PURPOSE: a typed turn's
|
|
26
|
+
* turn-end text has no chat to deliver to (deliver() only runs the redundant
|
|
27
|
+
* idle), and the agent acts through its sandbox CLI instead — identical to
|
|
28
|
+
* the standard runtimes, where typed events are answered with CLI actions,
|
|
29
|
+
* not auto-delivered text. approval_decided is the one exception: it lives
|
|
30
|
+
* in a chat, so its plan carries the chatId and the reply auto-delivers
|
|
31
|
+
* there like any chat message.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface TypedDispatchData {
|
|
35
|
+
id?: unknown;
|
|
36
|
+
event_type?: unknown;
|
|
37
|
+
source_type?: unknown;
|
|
38
|
+
source_id?: unknown;
|
|
39
|
+
chat_id?: unknown;
|
|
40
|
+
task_id?: unknown;
|
|
41
|
+
actor_id?: unknown;
|
|
42
|
+
delivery_reason?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Dispatch event types this module consumes (everything but the message pair). */
|
|
46
|
+
export const TYPED_EVENT_TYPES = [
|
|
47
|
+
'task_assign',
|
|
48
|
+
'task_update',
|
|
49
|
+
'task_comment',
|
|
50
|
+
'wiki_comment',
|
|
51
|
+
'schedule.fire',
|
|
52
|
+
'external_trigger',
|
|
53
|
+
'approval_decided',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export function isTypedEventType(eventType: string): boolean {
|
|
57
|
+
return (TYPED_EVENT_TYPES as readonly string[]).includes(eventType);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the inbound pipeline needs to emit + report one typed event. */
|
|
61
|
+
export interface TypedEventPlan {
|
|
62
|
+
outcome: 'emit';
|
|
63
|
+
/** F2 grouping key + agent-event correlation anchor (task/schedule/… id). */
|
|
64
|
+
subject: string;
|
|
65
|
+
/** Framed event body (the envelope's data.text). */
|
|
66
|
+
body: string;
|
|
67
|
+
/** Input-step routing; null skips the step (no meaningful target). */
|
|
68
|
+
stepTarget: { type: string; id: string } | null;
|
|
69
|
+
/** Input-step content bits. */
|
|
70
|
+
triggerType: string;
|
|
71
|
+
summary: string;
|
|
72
|
+
/** Set only for approval_decided: the reply auto-delivers to this chat. */
|
|
73
|
+
chatId?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type TypedDispatchResult =
|
|
77
|
+
| TypedEventPlan
|
|
78
|
+
/** Permanently unusable — ack and drop. */
|
|
79
|
+
| { outcome: 'stale' }
|
|
80
|
+
/** Transient failure — leave pending for the catch-up sweep. */
|
|
81
|
+
| { outcome: 'pending' };
|
|
82
|
+
|
|
83
|
+
const STALE: TypedDispatchResult = { outcome: 'stale' };
|
|
84
|
+
const PENDING: TypedDispatchResult = { outcome: 'pending' };
|
|
85
|
+
|
|
86
|
+
function sanitizeMeta(value: string): string {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/[\r\n]+/g, ' ')
|
|
89
|
+
.replace(/[[\]|]/g, ' ')
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the plan for one typed dispatch. Never throws: any unexpected error
|
|
95
|
+
* degrades to `pending` (same posture as the message path — the sweep
|
|
96
|
+
* retries, and a poisoned row eventually surfaces as a stuck pending count).
|
|
97
|
+
*/
|
|
98
|
+
export async function planTypedDispatch(
|
|
99
|
+
data: TypedDispatchData,
|
|
100
|
+
ctx: ConnectorContext,
|
|
101
|
+
): Promise<TypedDispatchResult> {
|
|
102
|
+
const eventType = String(data.event_type ?? '');
|
|
103
|
+
const sourceId = data.source_id ? String(data.source_id) : '';
|
|
104
|
+
try {
|
|
105
|
+
switch (eventType) {
|
|
106
|
+
case 'task_assign':
|
|
107
|
+
case 'task_update': {
|
|
108
|
+
const taskId = data.task_id ? String(data.task_id) : sourceId;
|
|
109
|
+
if (!taskId) return STALE;
|
|
110
|
+
return await planTask(ctx, taskId, eventType, actorId(data));
|
|
111
|
+
}
|
|
112
|
+
case 'task_comment': {
|
|
113
|
+
const taskId = data.task_id ? String(data.task_id) : '';
|
|
114
|
+
if (!sourceId || !taskId) return STALE;
|
|
115
|
+
return await planTaskComment(ctx, sourceId, taskId, actorId(data), deliveryReason(data));
|
|
116
|
+
}
|
|
117
|
+
case 'wiki_comment': {
|
|
118
|
+
if (!sourceId) return STALE;
|
|
119
|
+
return await planWikiComment(ctx, sourceId, actorId(data), deliveryReason(data));
|
|
120
|
+
}
|
|
121
|
+
case 'schedule.fire': {
|
|
122
|
+
if (!sourceId) return STALE;
|
|
123
|
+
return await planScheduleFire(ctx, sourceId);
|
|
124
|
+
}
|
|
125
|
+
case 'external_trigger': {
|
|
126
|
+
if (!sourceId) return STALE;
|
|
127
|
+
return await planExternalTrigger(ctx, sourceId);
|
|
128
|
+
}
|
|
129
|
+
case 'approval_decided': {
|
|
130
|
+
if (!sourceId) return STALE;
|
|
131
|
+
const chatId = data.chat_id ? String(data.chat_id) : undefined;
|
|
132
|
+
return await planApprovalDecided(ctx, sourceId, chatId, actorId(data));
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
return PENDING; // unknown typed event — future work, never ack-drop
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(`[parel-channel] typed dispatch ${eventType} failed`, err);
|
|
139
|
+
return PENDING;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function actorId(data: TypedDispatchData): string | undefined {
|
|
144
|
+
return data.actor_id ? String(data.actor_id) : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function deliveryReason(data: TypedDispatchData): string | undefined {
|
|
148
|
+
return data.delivery_reason ? String(data.delivery_reason) : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function orgGet(ctx: ConnectorContext, path: string): Promise<Response> {
|
|
152
|
+
return fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}${path}`, {
|
|
153
|
+
headers: { Authorization: `Bearer ${requireAgk(ctx)}` },
|
|
154
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 404/403 are permanent for a dispatch pointer; anything else is transient. */
|
|
159
|
+
function goneStatus(status: number): boolean {
|
|
160
|
+
return status === 404 || status === 403;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// task_assign / task_update
|
|
165
|
+
|
|
166
|
+
interface TaskRow {
|
|
167
|
+
id?: string;
|
|
168
|
+
identifier?: string;
|
|
169
|
+
title?: string;
|
|
170
|
+
status?: string;
|
|
171
|
+
priority?: string;
|
|
172
|
+
description?: string;
|
|
173
|
+
project_id?: string;
|
|
174
|
+
parent_id?: string;
|
|
175
|
+
assignee_id?: string;
|
|
176
|
+
creator_id?: string;
|
|
177
|
+
updated_at?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function planTask(
|
|
181
|
+
ctx: ConnectorContext,
|
|
182
|
+
taskId: string,
|
|
183
|
+
eventType: 'task_assign' | 'task_update',
|
|
184
|
+
actor?: string,
|
|
185
|
+
): Promise<TypedDispatchResult> {
|
|
186
|
+
const res = await orgGet(ctx, `/tasks/${taskId}`);
|
|
187
|
+
if (!res.ok) return goneStatus(res.status) ? STALE : PENDING;
|
|
188
|
+
const task = (await res.json().catch(() => null)) as TaskRow | null;
|
|
189
|
+
if (!task?.id) return STALE;
|
|
190
|
+
|
|
191
|
+
// Same staleness rule as the standard runtimes (gateway handleTaskDispatch):
|
|
192
|
+
// an update only concerns this agent while it is the assignee, or — for
|
|
193
|
+
// updates — the creator watching its own delegation. Anything else is a
|
|
194
|
+
// stale row (reassigned before delivery) — ack it away.
|
|
195
|
+
const me = String((ctx.config as Record<string, unknown>).parallAgentId ?? '');
|
|
196
|
+
const isAssignee = task.assignee_id === me;
|
|
197
|
+
const isCreatorUpdate = eventType === 'task_update' && task.creator_id === me;
|
|
198
|
+
if (me && !isAssignee && !isCreatorUpdate) return STALE;
|
|
199
|
+
|
|
200
|
+
// Metadata lines pass through sanitizeMeta — a task title with newlines or
|
|
201
|
+
// brackets must not be able to forge [Event: …] header lines (the body
|
|
202
|
+
// below is user content by definition and stays verbatim).
|
|
203
|
+
const label = task.identifier
|
|
204
|
+
? `${sanitizeMeta(task.identifier)} (prll://${task.id})`
|
|
205
|
+
: `prll://${task.id}`;
|
|
206
|
+
// Standard runtimes collapse task_update onto the SAME task.assigned event
|
|
207
|
+
// body (agent-core handleTaskDispatch → handleTaskAssignment) — managed
|
|
208
|
+
// agents must read the identical contract, and the shared task skill only
|
|
209
|
+
// teaches [Event: task.assigned].
|
|
210
|
+
const lines = [`[Event: task.assigned]`, `[Task: ${label}]`];
|
|
211
|
+
const by = actor ?? task.creator_id;
|
|
212
|
+
if (by) {
|
|
213
|
+
lines.push(`[Assigned by: prll://${by}]`);
|
|
214
|
+
}
|
|
215
|
+
lines.push(`Title: ${sanitizeMeta(task.title ?? '')}`);
|
|
216
|
+
lines.push(
|
|
217
|
+
`Status: ${sanitizeMeta(task.status ?? '')}`,
|
|
218
|
+
`Priority: ${sanitizeMeta(task.priority ?? '')}`,
|
|
219
|
+
);
|
|
220
|
+
if (task.project_id) lines.push(`Project: prll://${task.project_id}`);
|
|
221
|
+
if (task.parent_id) lines.push(`Parent: prll://${task.parent_id}`);
|
|
222
|
+
if (task.description) lines.push('', task.description);
|
|
223
|
+
lines.push('', taskActionHint(task.id));
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
outcome: 'emit',
|
|
227
|
+
subject: task.id,
|
|
228
|
+
body: lines.join('\n'),
|
|
229
|
+
stepTarget: { type: 'task', id: task.id },
|
|
230
|
+
triggerType: eventType,
|
|
231
|
+
summary: `${eventType === 'task_assign' ? 'Task assigned' : 'Task updated'}: ${task.title ?? task.id}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* schedule.fire / external_trigger turns have NO delivery target — the
|
|
237
|
+
* managed-agent base behavior ("your reply auto-delivers") does not apply,
|
|
238
|
+
* so without this line a normal completion silently disappears.
|
|
239
|
+
*/
|
|
240
|
+
function cliOnlyOutputHint(kind: 'schedule' | 'trigger'): string {
|
|
241
|
+
return `<system-reminder>This ${kind} event has NO conversation attached — your plain text output is NOT delivered anywhere. To surface results, act through your sandbox CLI (e.g. \`parall messages send <chatId> --text "…"\` to a relevant chat, \`parall tasks update …\`, or the target referenced above).</system-reminder>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function taskActionHint(taskId: string): string {
|
|
245
|
+
return `<system-reminder>Act on this task with your sandbox CLI: \`parall tasks get ${taskId}\`, \`parall tasks update ${taskId} --status …\`, \`parall tasks comments add ${taskId} --body "…"\`. Your plain text output is NOT delivered anywhere for task events.</system-reminder>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// task_comment / wiki_comment
|
|
250
|
+
|
|
251
|
+
interface CommentRow {
|
|
252
|
+
id?: string;
|
|
253
|
+
body?: string;
|
|
254
|
+
author_id?: string;
|
|
255
|
+
author?: { display_name?: string };
|
|
256
|
+
target_uri?: string;
|
|
257
|
+
hints?: { no_reply?: boolean };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function fetchComment(
|
|
261
|
+
ctx: ConnectorContext,
|
|
262
|
+
commentId: string,
|
|
263
|
+
): Promise<{ comment: CommentRow } | TypedDispatchResult> {
|
|
264
|
+
const res = await orgGet(ctx, `/comments/${commentId}`);
|
|
265
|
+
if (!res.ok) return goneStatus(res.status) ? STALE : PENDING;
|
|
266
|
+
const comment = (await res.json().catch(() => null)) as CommentRow | null;
|
|
267
|
+
if (!comment?.id) return STALE;
|
|
268
|
+
// no_reply-hinted comments are informational — same drop the gateway does.
|
|
269
|
+
if (comment.hints?.no_reply) return STALE;
|
|
270
|
+
return { comment };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function planTaskComment(
|
|
274
|
+
ctx: ConnectorContext,
|
|
275
|
+
commentId: string,
|
|
276
|
+
taskId: string,
|
|
277
|
+
actor: string | undefined,
|
|
278
|
+
reason: string | undefined,
|
|
279
|
+
): Promise<TypedDispatchResult> {
|
|
280
|
+
const fetched = await fetchComment(ctx, commentId);
|
|
281
|
+
if (!('comment' in fetched)) return fetched;
|
|
282
|
+
const comment = fetched.comment;
|
|
283
|
+
|
|
284
|
+
// Task context is optional garnish (the gateway treats it the same way).
|
|
285
|
+
let task: TaskRow | null = null;
|
|
286
|
+
try {
|
|
287
|
+
const res = await orgGet(ctx, `/tasks/${taskId}`);
|
|
288
|
+
if (res.ok) task = (await res.json().catch(() => null)) as TaskRow | null;
|
|
289
|
+
} catch {
|
|
290
|
+
/* optional */
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const lines = [`[Event: task.comment.created]`];
|
|
294
|
+
const label = task?.identifier
|
|
295
|
+
? `${sanitizeMeta(task.identifier)} (prll://${taskId})`
|
|
296
|
+
: `prll://${taskId}`;
|
|
297
|
+
lines.push(`[Task: ${label}]`);
|
|
298
|
+
if (task?.title) lines.push(`Title: ${sanitizeMeta(task.title)}`);
|
|
299
|
+
if (task?.status || task?.priority) {
|
|
300
|
+
// Task context parity with agent-core's task_comment body.
|
|
301
|
+
lines.push(
|
|
302
|
+
`Status: ${sanitizeMeta(task.status ?? '')}`,
|
|
303
|
+
`Priority: ${sanitizeMeta(task.priority ?? '')}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (reason) lines.push(`[Delivery: ${sanitizeMeta(reason)}]`);
|
|
307
|
+
const author = comment.author?.display_name ?? actor ?? 'unknown';
|
|
308
|
+
lines.push(`[From: ${sanitizeMeta(author)} (prll://${comment.author_id ?? ''})]`);
|
|
309
|
+
lines.push(`[Comment ID: prll://${comment.id}]`);
|
|
310
|
+
lines.push('', comment.body ?? '');
|
|
311
|
+
lines.push('', taskActionHint(taskId));
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
outcome: 'emit',
|
|
315
|
+
subject: taskId,
|
|
316
|
+
body: lines.join('\n'),
|
|
317
|
+
stepTarget: { type: 'task', id: taskId },
|
|
318
|
+
triggerType: 'task_comment',
|
|
319
|
+
summary: `Task comment by ${author}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function planWikiComment(
|
|
324
|
+
ctx: ConnectorContext,
|
|
325
|
+
commentId: string,
|
|
326
|
+
actor: string | undefined,
|
|
327
|
+
reason: string | undefined,
|
|
328
|
+
): Promise<TypedDispatchResult> {
|
|
329
|
+
const fetched = await fetchComment(ctx, commentId);
|
|
330
|
+
if (!('comment' in fetched)) return fetched;
|
|
331
|
+
const comment = fetched.comment;
|
|
332
|
+
|
|
333
|
+
// Routing key = scheme-stripped target_uri (same as agent-core's
|
|
334
|
+
// parseWikiCommentTarget): distinct pages / anchors / changesets group
|
|
335
|
+
// into distinct subjects instead of colliding on one wiki id.
|
|
336
|
+
const targetUri = comment.target_uri ?? '';
|
|
337
|
+
const routingKey = targetUri.replace(/^prll:\/\//, '') || `comment:${comment.id}`;
|
|
338
|
+
const isChangeset = routingKey.startsWith('wcs_');
|
|
339
|
+
|
|
340
|
+
const lines = [`[Event: wiki.comment.created]`];
|
|
341
|
+
lines.push(`[${isChangeset ? 'Wiki Changeset' : 'Wiki'}: prll://${routingKey}]`);
|
|
342
|
+
if (reason) lines.push(`[Delivery: ${sanitizeMeta(reason)}]`);
|
|
343
|
+
const author = comment.author?.display_name ?? actor ?? 'unknown';
|
|
344
|
+
lines.push(`[From: ${sanitizeMeta(author)} (prll://${comment.author_id ?? ''})]`);
|
|
345
|
+
lines.push(`[Comment ID: prll://${comment.id}]`);
|
|
346
|
+
lines.push('', comment.body ?? '');
|
|
347
|
+
lines.push(
|
|
348
|
+
'',
|
|
349
|
+
`<system-reminder>Reply with your sandbox CLI: \`parall comments add --target prll://${routingKey} --body "…"\` (read context first with \`parall comments get ${comment.id}\`). Your plain text output is NOT delivered anywhere for wiki events.</system-reminder>`,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
outcome: 'emit',
|
|
354
|
+
subject: routingKey,
|
|
355
|
+
body: lines.join('\n'),
|
|
356
|
+
stepTarget: { type: 'wiki', id: routingKey },
|
|
357
|
+
triggerType: 'wiki_comment',
|
|
358
|
+
summary: `Wiki comment by ${author}`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// schedule.fire
|
|
364
|
+
|
|
365
|
+
interface ScheduleRunRow {
|
|
366
|
+
id?: string;
|
|
367
|
+
schedule_id?: string;
|
|
368
|
+
fired_description?: string;
|
|
369
|
+
scheduled_fire_at?: string;
|
|
370
|
+
fired_attached_uri?: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function planScheduleFire(
|
|
374
|
+
ctx: ConnectorContext,
|
|
375
|
+
runId: string,
|
|
376
|
+
): Promise<TypedDispatchResult> {
|
|
377
|
+
const res = await orgGet(ctx, `/schedule_runs/${runId}`);
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
if (res.status === 404) {
|
|
380
|
+
// Contract: warn + ack + drop (a post-fire PATCH can revoke access to
|
|
381
|
+
// a delivered snapshot — expected, but keep it findable in triage).
|
|
382
|
+
console.warn(`[parel-channel] schedule run ${runId} not accessible (404), dropping`);
|
|
383
|
+
return STALE;
|
|
384
|
+
}
|
|
385
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
386
|
+
}
|
|
387
|
+
const run = (await res.json().catch(() => null)) as ScheduleRunRow | null;
|
|
388
|
+
if (!run?.id || !run.schedule_id) return STALE;
|
|
389
|
+
|
|
390
|
+
const lines = [`[Event: schedule.fired]`];
|
|
391
|
+
lines.push(`[Schedule: prll://${run.schedule_id}]`);
|
|
392
|
+
lines.push(`[Run: prll://${run.id}]`);
|
|
393
|
+
if (run.scheduled_fire_at) lines.push(`[Scheduled at: ${sanitizeMeta(run.scheduled_fire_at)}]`);
|
|
394
|
+
if (run.fired_attached_uri) lines.push(`[Attached: ${sanitizeMeta(run.fired_attached_uri)}]`);
|
|
395
|
+
lines.push('', run.fired_description ?? '');
|
|
396
|
+
lines.push('', cliOnlyOutputHint('schedule'));
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
outcome: 'emit',
|
|
400
|
+
subject: run.schedule_id,
|
|
401
|
+
body: lines.join('\n'),
|
|
402
|
+
stepTarget: { type: 'schedule', id: run.schedule_id },
|
|
403
|
+
triggerType: 'schedule_fire',
|
|
404
|
+
summary: `Schedule fired: ${(run.fired_description ?? run.id).slice(0, 120)}`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// external_trigger
|
|
410
|
+
|
|
411
|
+
interface ExternalTriggerRunRow {
|
|
412
|
+
id?: string;
|
|
413
|
+
trigger_id?: string;
|
|
414
|
+
trigger_name?: string;
|
|
415
|
+
agent_input_body?: string;
|
|
416
|
+
connection_id?: string;
|
|
417
|
+
connection_display_name?: string;
|
|
418
|
+
connection_source_type?: string;
|
|
419
|
+
ingress_event_id?: string;
|
|
420
|
+
ingress_event_type?: string;
|
|
421
|
+
trigger_snapshot?: { attached_to_uri?: unknown };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function planExternalTrigger(
|
|
425
|
+
ctx: ConnectorContext,
|
|
426
|
+
runId: string,
|
|
427
|
+
): Promise<TypedDispatchResult> {
|
|
428
|
+
const res = await orgGet(ctx, `/external-trigger-runs/${runId}`);
|
|
429
|
+
if (!res.ok) {
|
|
430
|
+
if (res.status === 404) {
|
|
431
|
+
console.warn(`[parel-channel] external trigger run ${runId} not accessible (404), dropping`);
|
|
432
|
+
return STALE;
|
|
433
|
+
}
|
|
434
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
435
|
+
}
|
|
436
|
+
const run = (await res.json().catch(() => null)) as ExternalTriggerRunRow | null;
|
|
437
|
+
if (!run?.id || !run.trigger_id) return STALE;
|
|
438
|
+
|
|
439
|
+
const lines = [`[Event: external.trigger]`];
|
|
440
|
+
const triggerLabel = run.trigger_name
|
|
441
|
+
? `${sanitizeMeta(run.trigger_name)} (prll://${run.trigger_id})`
|
|
442
|
+
: `prll://${run.trigger_id}`;
|
|
443
|
+
lines.push(`[Trigger: ${triggerLabel}]`);
|
|
444
|
+
lines.push(`[Run: prll://${run.id}]`);
|
|
445
|
+
if (run.connection_id) {
|
|
446
|
+
const label = run.connection_display_name
|
|
447
|
+
? `${sanitizeMeta(run.connection_display_name)} (prll://${run.connection_id})`
|
|
448
|
+
: `prll://${run.connection_id}`;
|
|
449
|
+
lines.push(`[Connection: ${label}]`);
|
|
450
|
+
}
|
|
451
|
+
if (run.ingress_event_id) lines.push(`[Ingress: prll://${run.ingress_event_id}]`);
|
|
452
|
+
const attached = run.trigger_snapshot?.attached_to_uri;
|
|
453
|
+
if (typeof attached === 'string' && attached) lines.push(`[Attached: ${sanitizeMeta(attached)}]`);
|
|
454
|
+
if (run.connection_source_type) {
|
|
455
|
+
lines.push(`[Source: ${sanitizeMeta(run.connection_source_type)}]`);
|
|
456
|
+
}
|
|
457
|
+
if (run.ingress_event_type) {
|
|
458
|
+
lines.push(`[External event: ${sanitizeMeta(run.ingress_event_type)}]`);
|
|
459
|
+
}
|
|
460
|
+
lines.push('', run.agent_input_body ?? '');
|
|
461
|
+
lines.push('', cliOnlyOutputHint('trigger'));
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
outcome: 'emit',
|
|
465
|
+
subject: run.trigger_id,
|
|
466
|
+
body: lines.join('\n'),
|
|
467
|
+
stepTarget: { type: 'external_trigger', id: run.trigger_id },
|
|
468
|
+
triggerType: 'external_trigger',
|
|
469
|
+
summary: `External trigger: ${run.trigger_name ?? run.trigger_id}`,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// approval_decided
|
|
475
|
+
|
|
476
|
+
interface ApprovalRow {
|
|
477
|
+
id?: string;
|
|
478
|
+
title?: string;
|
|
479
|
+
status?: string;
|
|
480
|
+
execution_status?: string;
|
|
481
|
+
chat_id?: string;
|
|
482
|
+
decided_by?: string;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function planApprovalDecided(
|
|
486
|
+
ctx: ConnectorContext,
|
|
487
|
+
approvalId: string,
|
|
488
|
+
chatId: string | undefined,
|
|
489
|
+
actor: string | undefined,
|
|
490
|
+
): Promise<TypedDispatchResult> {
|
|
491
|
+
// Approvals are NOT org-scoped in the API surface (ENDPOINTS.APPROVAL).
|
|
492
|
+
const res = await fetch(`${parallApiUrl(ctx)}/api/v1/approvals/${approvalId}`, {
|
|
493
|
+
headers: { Authorization: `Bearer ${requireAgk(ctx)}` },
|
|
494
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
495
|
+
});
|
|
496
|
+
if (!res.ok) return goneStatus(res.status) ? STALE : PENDING;
|
|
497
|
+
const approval = (await res.json().catch(() => null)) as ApprovalRow | null;
|
|
498
|
+
if (!approval?.id) return STALE;
|
|
499
|
+
|
|
500
|
+
const statusLabel = approval.status === 'approved' ? 'Approved' : 'Rejected';
|
|
501
|
+
const execInfo = approval.execution_status ? ` | execution: ${approval.execution_status}` : '';
|
|
502
|
+
const resolvedChat = chatId ?? approval.chat_id;
|
|
503
|
+
|
|
504
|
+
const lines = [`[Event: approval.decided]`];
|
|
505
|
+
lines.push(`[Approval: prll://${approval.id}]`);
|
|
506
|
+
if (resolvedChat) lines.push(`[Chat: prll://${resolvedChat}]`);
|
|
507
|
+
lines.push(`[Decided by: ${sanitizeMeta(actor ?? approval.decided_by ?? 'system')}]`);
|
|
508
|
+
lines.push('', `${statusLabel}: ${approval.title ?? ''}${execInfo}`);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
outcome: 'emit',
|
|
512
|
+
// The decision belongs to the chat conversation it happened in — group
|
|
513
|
+
// and correlate there, and let the turn-end reply deliver to that chat.
|
|
514
|
+
subject: resolvedChat ?? approval.id,
|
|
515
|
+
body: lines.join('\n'),
|
|
516
|
+
stepTarget: resolvedChat ? { type: 'chat', id: resolvedChat } : null,
|
|
517
|
+
triggerType: 'approval_decided',
|
|
518
|
+
summary: `${statusLabel}: ${approval.title ?? approval.id}`,
|
|
519
|
+
chatId: resolvedChat,
|
|
520
|
+
};
|
|
521
|
+
}
|