@parall/parel-channel 1.40.0 → 1.42.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 +13 -4
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +21 -3
- 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 +23 -16
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +89 -92
- package/dist/fork.d.ts +148 -0
- package/dist/fork.d.ts.map +1 -0
- package/dist/fork.js +323 -0
- package/dist/inbound-channel.d.ts +29 -0
- package/dist/inbound-channel.d.ts.map +1 -0
- package/dist/inbound-channel.js +219 -0
- package/dist/inbound.d.ts +14 -0
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +639 -272
- 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 +155 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +391 -13
- package/dist/typed-dispatch.d.ts +82 -0
- package/dist/typed-dispatch.d.ts.map +1 -0
- package/dist/typed-dispatch.js +347 -0
- package/package.json +1 -1
- package/src/channel-prompt.ts +32 -4
- package/src/connect.ts +10 -0
- package/src/delivery.ts +91 -92
- package/src/fork.ts +435 -0
- package/src/inbound-channel.ts +264 -0
- package/src/inbound.ts +725 -308
- package/src/index.ts +7 -1
- package/src/parel-sdk-compat.ts +77 -0
- package/src/session.ts +448 -13
- package/src/typed-dispatch.ts +529 -0
package/dist/session.js
CHANGED
|
@@ -35,7 +35,54 @@ export function reportingEnabled(ctx) {
|
|
|
35
35
|
* simply never read again.)
|
|
36
36
|
*/
|
|
37
37
|
export const TURN_ENVELOPE_KEY = `turnEnvelope:${MAIN_SESSION_KEY}`;
|
|
38
|
-
const SESSION_CACHE_KEY = `session:${MAIN_SESSION_KEY}`;
|
|
38
|
+
export const SESSION_CACHE_KEY = `session:${MAIN_SESSION_KEY}`;
|
|
39
|
+
const childSessionCacheKey = (childRef) => `sessionChild:${childRef}`;
|
|
40
|
+
/**
|
|
41
|
+
* Conversation key for envelope subjects and fork routing. Thread-aware:
|
|
42
|
+
* parel's turn grouping (F2) and mid-turn injection (F3) key on the envelope
|
|
43
|
+
* subject, and a turn carries ONE reply route — grouping two threads of the
|
|
44
|
+
* same chat under one subject would absorb thread B into thread A's turn and
|
|
45
|
+
* deliver its reply to the wrong thread (agent-core's lane keys draw the
|
|
46
|
+
* same thread/channel line). `~` is outside both nanoid and ULID alphabets,
|
|
47
|
+
* so the composite never collides with a plain id.
|
|
48
|
+
*/
|
|
49
|
+
export function conversationKey(chatId, threadRootId) {
|
|
50
|
+
return threadRootId ? `${chatId}~${threadRootId}` : chatId;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The addressable entity id inside a (possibly thread-composite) subject.
|
|
54
|
+
* Splits ONLY the `cht_…~msg_…` composite conversationKey produces — typed
|
|
55
|
+
* wiki subjects are scheme-stripped paths where `~` is a legal character
|
|
56
|
+
* (e.g. `wik_1/docs/~draft.md`) and must pass through untouched.
|
|
57
|
+
*/
|
|
58
|
+
export function subjectEntityId(subject) {
|
|
59
|
+
const sep = subject.indexOf('~');
|
|
60
|
+
if (sep === -1)
|
|
61
|
+
return subject;
|
|
62
|
+
const head = subject.slice(0, sep);
|
|
63
|
+
const tail = subject.slice(sep + 1);
|
|
64
|
+
return head.startsWith('cht_') && tail.startsWith('msg_') ? head : subject;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Step broadcast routing by subject shape: chat ids and channel conversations
|
|
68
|
+
* have live subscribers; task/schedule/trigger/wiki subjects are stored-only
|
|
69
|
+
* targets (the server publishes step WS events for chat/task targets and
|
|
70
|
+
* simply persists the rest). Mirrors agent-core's resolveStepTarget so both
|
|
71
|
+
* runtimes' session panels read identically.
|
|
72
|
+
*/
|
|
73
|
+
export function stepTargetTypeFor(subject) {
|
|
74
|
+
if (subject.startsWith('chv_'))
|
|
75
|
+
return 'channel_conversation';
|
|
76
|
+
if (subject.startsWith('tsk_'))
|
|
77
|
+
return 'task';
|
|
78
|
+
if (subject.startsWith('sch_'))
|
|
79
|
+
return 'schedule';
|
|
80
|
+
if (subject.startsWith('xtr_'))
|
|
81
|
+
return 'external_trigger';
|
|
82
|
+
if (subject.startsWith('wcs_') || subject.startsWith('wik_'))
|
|
83
|
+
return 'wiki';
|
|
84
|
+
return 'chat';
|
|
85
|
+
}
|
|
39
86
|
function resultForStatus(status) {
|
|
40
87
|
return status === 404 || status === 409 || status === 410 ? 'stale' : 'failed';
|
|
41
88
|
}
|
|
@@ -87,6 +134,87 @@ export async function ensureSession(ctx, opts = {}) {
|
|
|
87
134
|
return null;
|
|
88
135
|
}
|
|
89
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the CURRENT main ase_ from the server, bypassing the cache — the
|
|
139
|
+
* onOpen generation check needs the authoritative id to compare against the
|
|
140
|
+
* cached one (New Session rotates it). Updates the cache on success; returns
|
|
141
|
+
* null on failure WITHOUT touching the cache (the caller must not lose its
|
|
142
|
+
* only generation token to a transient error).
|
|
143
|
+
*/
|
|
144
|
+
export async function fetchMainSessionFresh(ctx) {
|
|
145
|
+
if (!reportingEnabled(ctx))
|
|
146
|
+
return null;
|
|
147
|
+
try {
|
|
148
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions`;
|
|
149
|
+
const res = await fetch(url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: jsonHeaders(ctx),
|
|
152
|
+
body: JSON.stringify({ runtime_type: 'parel', runtime_session_id: MAIN_SESSION_KEY }),
|
|
153
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
return null;
|
|
157
|
+
const body = (await res.json().catch(() => null));
|
|
158
|
+
const id = body?.id;
|
|
159
|
+
if (!id)
|
|
160
|
+
return null;
|
|
161
|
+
await ctx.store?.set(SESSION_CACHE_KEY, id).catch(() => { });
|
|
162
|
+
return id;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Get-or-create the parall AgentSession mirroring one fork child session
|
|
170
|
+
* (ase_ with parent_session_id → the main ase_, runtime_session_id = the
|
|
171
|
+
* opaque childRef — the connector never sees parel's internal session id).
|
|
172
|
+
* Same caching/degradation posture as ensureSession; the parent resolve
|
|
173
|
+
* reuses the main-session cache so the extra cost is one store read.
|
|
174
|
+
*/
|
|
175
|
+
export async function ensureChildSession(ctx, childRef, opts = {}) {
|
|
176
|
+
if (!reportingEnabled(ctx))
|
|
177
|
+
return null;
|
|
178
|
+
const cacheKey = childSessionCacheKey(childRef);
|
|
179
|
+
const cached = await ctx.store?.get(cacheKey).catch(() => null);
|
|
180
|
+
if (typeof cached === 'string' && cached)
|
|
181
|
+
return cached;
|
|
182
|
+
const parentId = await ensureSession(ctx, opts);
|
|
183
|
+
try {
|
|
184
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions`;
|
|
185
|
+
const res = await fetch(url, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: jsonHeaders(ctx),
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
runtime_type: 'parel',
|
|
190
|
+
runtime_session_id: childRef,
|
|
191
|
+
// Forks sit outside the main-lane uniqueness (parent set), mirroring
|
|
192
|
+
// the standard runtimes' fork ase_ semantics.
|
|
193
|
+
runtime_lane_key: `agent:fork:parel:${childRef}`,
|
|
194
|
+
...(parentId ? { parent_session_id: parentId } : {}),
|
|
195
|
+
}),
|
|
196
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
console.error(`[parel-channel] child session get-or-create failed (${res.status})`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const body = (await res.json().catch(() => null));
|
|
203
|
+
const id = body?.id;
|
|
204
|
+
if (!id)
|
|
205
|
+
return null;
|
|
206
|
+
await ctx.store?.set(cacheKey, id).catch(() => { });
|
|
207
|
+
return id;
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
console.error('[parel-channel] child session get-or-create failed', err);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Drop one child session's cache row (its ase_ was closed under us). */
|
|
215
|
+
export async function invalidateChildSession(ctx, childRef) {
|
|
216
|
+
await ctx.store?.delete(childSessionCacheKey(childRef)).catch(() => { });
|
|
217
|
+
}
|
|
90
218
|
/** PATCH the session (status / trigger_message_id). Never throws. */
|
|
91
219
|
export async function patchSession(ctx, sessionId, body) {
|
|
92
220
|
try {
|
|
@@ -194,6 +322,44 @@ export async function createChannelInputStep(ctx, sessionId, args) {
|
|
|
194
322
|
return 'failed';
|
|
195
323
|
}
|
|
196
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Input step for a typed (non-message) dispatch — task/schedule/trigger/
|
|
327
|
+
* wiki-comment/approval events. Same processing-ring/panel role as the chat
|
|
328
|
+
* input step; the target routes the record to the source entity instead of a
|
|
329
|
+
* chat (typed dispatches have no message-level indicator, so the step is the
|
|
330
|
+
* panel timeline entry, not an indicator driver).
|
|
331
|
+
*/
|
|
332
|
+
export async function createTypedInputStep(ctx, sessionId, args) {
|
|
333
|
+
try {
|
|
334
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
335
|
+
const res = await fetch(url, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: jsonHeaders(ctx),
|
|
338
|
+
body: JSON.stringify({
|
|
339
|
+
step_type: 'input',
|
|
340
|
+
...(args.target ? { target_type: args.target.type, target_id: args.target.id } : {}),
|
|
341
|
+
// Session-scoped dedup — a failed ack leaves the dispatch replayable
|
|
342
|
+
// by the catch-up sweep, same rationale as the chat input step.
|
|
343
|
+
idempotency_key: `input:${args.dispatchId || args.sourceId}`,
|
|
344
|
+
content: {
|
|
345
|
+
trigger_type: args.triggerType,
|
|
346
|
+
trigger_ref: { source_id: args.sourceId },
|
|
347
|
+
...(args.senderId ? { sender_id: args.senderId } : {}),
|
|
348
|
+
summary: args.summary.slice(0, 200),
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
352
|
+
});
|
|
353
|
+
if (res.ok)
|
|
354
|
+
return 'ok';
|
|
355
|
+
console.error(`[parel-channel] typed input step failed (${res.status})`);
|
|
356
|
+
return resultForStatus(res.status);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
console.error('[parel-channel] typed input step failed', err);
|
|
360
|
+
return 'failed';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
197
363
|
const ERROR_TEXT_MAX = 500;
|
|
198
364
|
/**
|
|
199
365
|
* Credential redaction for runtime error strings that become user-visible
|
|
@@ -239,16 +405,18 @@ export async function createErrorStep(ctx, sessionId, args) {
|
|
|
239
405
|
const text = `Turn failed: ${redactSecrets(args.error, knownSecrets)}`.slice(0, ERROR_TEXT_MAX);
|
|
240
406
|
try {
|
|
241
407
|
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
242
|
-
// Same subject-
|
|
243
|
-
// target 'chat' or the broadcast lands on a channel no client
|
|
244
|
-
|
|
408
|
+
// Same subject-shape line createTraceStep draws: non-chat subjects must
|
|
409
|
+
// not target 'chat' or the broadcast lands on a channel no client
|
|
410
|
+
// watches. Thread-composite subjects address the chat itself.
|
|
411
|
+
const targetId = subjectEntityId(args.subject);
|
|
412
|
+
const targetType = stepTargetTypeFor(targetId);
|
|
245
413
|
const res = await fetch(url, {
|
|
246
414
|
method: 'POST',
|
|
247
415
|
headers: jsonHeaders(ctx),
|
|
248
416
|
body: JSON.stringify({
|
|
249
417
|
step_type: 'text',
|
|
250
418
|
target_type: targetType,
|
|
251
|
-
target_id:
|
|
419
|
+
target_id: targetId,
|
|
252
420
|
// At-most-once today (agent-event pushes are not replayed), but the
|
|
253
421
|
// turnId key makes a future retry path free of duplicate error rows.
|
|
254
422
|
idempotency_key: `error:${args.turnId}`,
|
|
@@ -331,15 +499,17 @@ export async function createTraceStep(ctx, sessionId, subject, event) {
|
|
|
331
499
|
}
|
|
332
500
|
try {
|
|
333
501
|
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
334
|
-
// The subject
|
|
335
|
-
// steps must not target 'chat' or the server would
|
|
336
|
-
// a messages:{
|
|
337
|
-
// resolveStepTarget draws the same line).
|
|
338
|
-
|
|
502
|
+
// The subject shape tells chat turns from external IM / typed-entity
|
|
503
|
+
// turns — non-chat steps must not target 'chat' or the server would
|
|
504
|
+
// broadcast them onto a messages:{…} channel no client subscribes to
|
|
505
|
+
// (agent-core's resolveStepTarget draws the same line). Thread-composite
|
|
506
|
+
// subjects address the chat itself.
|
|
507
|
+
const targetId = subjectEntityId(subject);
|
|
508
|
+
const targetType = stepTargetTypeFor(targetId);
|
|
339
509
|
const res = await fetch(url, {
|
|
340
510
|
method: 'POST',
|
|
341
511
|
headers: jsonHeaders(ctx),
|
|
342
|
-
body: JSON.stringify({ ...body, target_type: targetType, target_id:
|
|
512
|
+
body: JSON.stringify({ ...body, target_type: targetType, target_id: targetId }),
|
|
343
513
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
344
514
|
});
|
|
345
515
|
if (res.ok)
|
|
@@ -352,6 +522,50 @@ export async function createTraceStep(ctx, sessionId, subject, event) {
|
|
|
352
522
|
return 'failed';
|
|
353
523
|
}
|
|
354
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Ack a dispatch by source so it leaves the pending queue (idempotent).
|
|
527
|
+
* Typed as the fetch effect so it can ride BOTH hook channels (onMessage's
|
|
528
|
+
* ConnectorEffect[] and onAgentEvent's AgentEventEffect[] — the fork spawn
|
|
529
|
+
* ack fires from a child agent event).
|
|
530
|
+
*/
|
|
531
|
+
export function ackEffect(ctx, sourceType, sourceId) {
|
|
532
|
+
return {
|
|
533
|
+
type: 'fetch',
|
|
534
|
+
request: {
|
|
535
|
+
url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/ack`,
|
|
536
|
+
method: 'POST',
|
|
537
|
+
headers: {
|
|
538
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
539
|
+
'Content-Type': 'application/json',
|
|
540
|
+
},
|
|
541
|
+
body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
|
|
542
|
+
},
|
|
543
|
+
idempotencyKey: `ack:${sourceType}:${sourceId}`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Ack ONE dispatch WorkItem by its id. Typed dispatches must use this form:
|
|
548
|
+
* sibling WorkItems (e.g. a task mutation's task_assign + task_update rows)
|
|
549
|
+
* share (source_type, source_id), so the by-source ack would terminally
|
|
550
|
+
* resolve work that was never emitted. Message dispatches keep the by-source
|
|
551
|
+
* form (one WorkItem per message; the source ack is the established legacy
|
|
552
|
+
* contract).
|
|
553
|
+
*/
|
|
554
|
+
export function ackByIdEffect(ctx, dispatchId) {
|
|
555
|
+
return {
|
|
556
|
+
type: 'fetch',
|
|
557
|
+
request: {
|
|
558
|
+
url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/${dispatchId}/ack`,
|
|
559
|
+
method: 'POST',
|
|
560
|
+
headers: {
|
|
561
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
562
|
+
'Content-Type': 'application/json',
|
|
563
|
+
},
|
|
564
|
+
body: '{}',
|
|
565
|
+
},
|
|
566
|
+
idempotencyKey: `ackid:${dispatchId}`,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
355
569
|
/**
|
|
356
570
|
* Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
|
|
357
571
|
* before the ack effect is returned: MarkReceived only transitions rows still
|
|
@@ -359,8 +573,10 @@ export async function createTraceStep(ctx, sessionId, subject, event) {
|
|
|
359
573
|
* means the ring never appears.
|
|
360
574
|
*/
|
|
361
575
|
export async function markDispatchReceived(ctx, sourceType, sourceId) {
|
|
362
|
-
|
|
363
|
-
|
|
576
|
+
// Deliberately NOT gated on reportingEnabled: this is ledger surface, not
|
|
577
|
+
// session reporting — the URL needs no agent id, and with ack-on-emit gone
|
|
578
|
+
// the received transition is what keeps the reconnect sweep's TTL window
|
|
579
|
+
// (and the indicator lifecycle) working even for agent-id-less old configs.
|
|
364
580
|
try {
|
|
365
581
|
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
|
|
366
582
|
const res = await fetch(url, {
|
|
@@ -376,3 +592,165 @@ export async function markDispatchReceived(ctx, sourceType, sourceId) {
|
|
|
376
592
|
console.error('[parel-channel] mark received failed', err);
|
|
377
593
|
}
|
|
378
594
|
}
|
|
595
|
+
// ── By-source ledger surface ────────────────────────────
|
|
596
|
+
//
|
|
597
|
+
// The connector consumes dispatches without claiming lanes (parel's own
|
|
598
|
+
// queue serializes turns), so its ledger writes address WorkItems by source
|
|
599
|
+
// pointer, and its liveness bookkeeping is a per-source attempt fence in the
|
|
600
|
+
// durable store. Design: parel-reply-contract-alignment.md §5.3.
|
|
601
|
+
/**
|
|
602
|
+
* How long an emitted source may stay unresolved before the reconnect sweep
|
|
603
|
+
* re-emits it (crash / lost turn_completed self-heal — the successor of the
|
|
604
|
+
* retired turnwatch timer). Must comfortably exceed any legitimate turn.
|
|
605
|
+
*/
|
|
606
|
+
export const EMIT_TTL_MS = 30 * 60 * 1000;
|
|
607
|
+
function activeEmitKey(sourceType, sourceId) {
|
|
608
|
+
return `active:${sourceType}:${sourceId}`;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Per-source attempt fence. Written before emit, cleared by the matching
|
|
612
|
+
* turn event's complete: a turn event only closes a source whose stored id
|
|
613
|
+
* EXACTLY matches one of its envelopeIds — a late turn_completed from a
|
|
614
|
+
* superseded attempt (the source was re-emitted after the TTL) must not
|
|
615
|
+
* close the in-flight retry's work (codex P2 attempt fence).
|
|
616
|
+
*/
|
|
617
|
+
export async function readActiveEmit(ctx, sourceType, sourceId) {
|
|
618
|
+
const raw = await ctx.store?.get(activeEmitKey(sourceType, sourceId)).catch(() => null);
|
|
619
|
+
if (raw &&
|
|
620
|
+
typeof raw === 'object' &&
|
|
621
|
+
typeof raw.id === 'string' &&
|
|
622
|
+
typeof raw.at === 'number') {
|
|
623
|
+
return raw;
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
export async function writeActiveEmit(ctx, sourceType, sourceId, envelopeId) {
|
|
628
|
+
await ctx.store
|
|
629
|
+
?.set(activeEmitKey(sourceType, sourceId), { id: envelopeId, at: ctx.now() })
|
|
630
|
+
.catch(() => { });
|
|
631
|
+
}
|
|
632
|
+
export async function clearActiveEmit(ctx, sourceType, sourceId) {
|
|
633
|
+
await ctx.store?.delete(activeEmitKey(sourceType, sourceId)).catch(() => { });
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Deterministic envelope id for a source: `{sourceType}:{sourceId}` on the
|
|
637
|
+
* first attempt, `…#r{n}` on re-emits. Attempt-suffixed ids are unique per
|
|
638
|
+
* attempt, so a re-emit is a NEW envelope to parel regardless of any dedupe
|
|
639
|
+
* behavior — the retry always produces a fresh turn.
|
|
640
|
+
*/
|
|
641
|
+
export function nextEnvelopeId(sourceType, sourceId, prior) {
|
|
642
|
+
const base = `${sourceType}:${sourceId}`;
|
|
643
|
+
if (!prior)
|
|
644
|
+
return base;
|
|
645
|
+
const m = prior.id.match(/#r(\d+)$/);
|
|
646
|
+
const attempt = m ? Number(m[1]) + 1 : 1;
|
|
647
|
+
return `${base}#r${attempt}`;
|
|
648
|
+
}
|
|
649
|
+
/** envelope id → source pointer (strips the attempt suffix). */
|
|
650
|
+
export function sourceFromEnvelopeId(envelopeId) {
|
|
651
|
+
const bare = envelopeId.replace(/#r\d+$/, '');
|
|
652
|
+
const i = bare.indexOf(':');
|
|
653
|
+
if (i <= 0 || i === bare.length - 1)
|
|
654
|
+
return null;
|
|
655
|
+
return { sourceType: bare.slice(0, i), sourceId: bare.slice(i + 1) };
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Fence-checked turn-end ledger close for a set of envelope ids — the shared
|
|
659
|
+
* core of BOTH turn-end hooks: onAgentEvent (turn_completed/turn_failed, the
|
|
660
|
+
* primary) and deliver (the no-observe npm-fallback path, where turn events
|
|
661
|
+
* never arrive and this is the ONLY terminal ledger hook — without it a
|
|
662
|
+
* received row would re-emit on every TTL sweep forever). Only sources whose
|
|
663
|
+
* `active:{source}` store row matches the envelope id EXACTLY are completed
|
|
664
|
+
* (attempt fence); the fence rows clear on success. Both hooks running is
|
|
665
|
+
* harmless: whichever completes first consumes the fence, the other skips.
|
|
666
|
+
*/
|
|
667
|
+
export async function completeFencedEnvelopes(ctx, envelopeIds) {
|
|
668
|
+
const fenced = [];
|
|
669
|
+
for (const envId of envelopeIds) {
|
|
670
|
+
const src = sourceFromEnvelopeId(envId);
|
|
671
|
+
if (!src)
|
|
672
|
+
continue; // pre-alignment envelope id (dsp_*) — old rows were acked on emit
|
|
673
|
+
const active = await readActiveEmit(ctx, src.sourceType, src.sourceId);
|
|
674
|
+
if (active?.id === envId) {
|
|
675
|
+
fenced.push({ source_type: src.sourceType, source_id: src.sourceId });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (fenced.length > 0 && (await completeSources(ctx, fenced))) {
|
|
679
|
+
for (const src of fenced) {
|
|
680
|
+
await clearActiveEmit(ctx, src.source_type, src.source_id);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Turn-end ledger close: resolves the turn's folded sources on the server —
|
|
686
|
+
* broad-cover to the turn's reply Effect when one exists, no_action sweep
|
|
687
|
+
* otherwise (the server decides; POST /dispatch/complete-sources, idempotent).
|
|
688
|
+
* One immediate retry on failure; a still-failed call leaves the rows
|
|
689
|
+
* received and the catch-up TTL re-emit self-heals.
|
|
690
|
+
*/
|
|
691
|
+
export async function completeSources(ctx, sources) {
|
|
692
|
+
// Ledger surface — not gated on reportingEnabled (see markDispatchReceived).
|
|
693
|
+
if (sources.length === 0)
|
|
694
|
+
return true;
|
|
695
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete-sources`;
|
|
696
|
+
const attempt = async () => {
|
|
697
|
+
const res = await fetch(url, {
|
|
698
|
+
method: 'POST',
|
|
699
|
+
headers: jsonHeaders(ctx),
|
|
700
|
+
body: JSON.stringify({ sources }),
|
|
701
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
702
|
+
});
|
|
703
|
+
if (!res.ok)
|
|
704
|
+
console.error(`[parel-channel] complete-sources failed (${res.status})`);
|
|
705
|
+
return res.ok;
|
|
706
|
+
};
|
|
707
|
+
try {
|
|
708
|
+
if (await attempt())
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
// fall through to the single immediate retry
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
return await attempt();
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
console.error('[parel-channel] complete-sources failed', err);
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Turn-end plain text as a suppressed audit step — the runtime-standard
|
|
724
|
+
* Layer 0 posture (agent-dm-loop-prevention.md): recorded on the session,
|
|
725
|
+
* never delivered to the chat. No `projection` field, so the server's
|
|
726
|
+
* projection gate keeps it panel-only; `suppressed: true` is the content
|
|
727
|
+
* marker the standard bridges write for undelivered text.
|
|
728
|
+
*/
|
|
729
|
+
export async function createSuppressedTextStep(ctx, sessionId, args) {
|
|
730
|
+
try {
|
|
731
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
732
|
+
// Same subject-prefix line the other step writers draw: chv_ turns must
|
|
733
|
+
// not target 'chat' or the broadcast lands on a channel no client watches.
|
|
734
|
+
const targetType = args.subject.startsWith('chv_') ? 'channel_conversation' : 'chat';
|
|
735
|
+
const res = await fetch(url, {
|
|
736
|
+
method: 'POST',
|
|
737
|
+
headers: jsonHeaders(ctx),
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
step_type: 'text',
|
|
740
|
+
target_type: targetType,
|
|
741
|
+
target_id: args.subject,
|
|
742
|
+
idempotency_key: `text:${args.deliveryId}`,
|
|
743
|
+
content: { text: args.text, suppressed: true },
|
|
744
|
+
}),
|
|
745
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
746
|
+
});
|
|
747
|
+
if (res.ok)
|
|
748
|
+
return 'ok';
|
|
749
|
+
console.error(`[parel-channel] suppressed text step failed (${res.status})`);
|
|
750
|
+
return resultForStatus(res.status);
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
console.error('[parel-channel] suppressed text step failed', err);
|
|
754
|
+
return 'failed';
|
|
755
|
+
}
|
|
756
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Typed (non-message) dispatches → agent events.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the standard runtimes' coverage (agent-core gateway dispatch.new
|
|
6
|
+
* switch; hermes adapter's inline port): task_assign / task_update /
|
|
7
|
+
* task_comment / wiki_comment / schedule.fire / external_trigger /
|
|
8
|
+
* approval_decided. Each handler pulls the source entity, renders the same
|
|
9
|
+
* event-body line format agent-core's buildEventBody produces (so an agent
|
|
10
|
+
* moved between runtimes reads identical events), and returns a plan the
|
|
11
|
+
* inbound pipeline turns into envelope + progress reporting + ack.
|
|
12
|
+
*
|
|
13
|
+
* Semantics carried over from the standard runtimes:
|
|
14
|
+
* - Permanently-unusable sources (404/403, deleted comments, no_reply hints,
|
|
15
|
+
* stale task assignment) resolve to `stale` — the caller acks and drops.
|
|
16
|
+
* - Transient fetch failures resolve to `pending` — the dispatch stays
|
|
17
|
+
* pending and the reconnect catch-up sweep retries it.
|
|
18
|
+
* - schedule.fire tolerates 404 by contract (post-fire PATCH on target_ids /
|
|
19
|
+
* attached_to_uri legitimately revokes access to a delivered run snapshot;
|
|
20
|
+
* docs/primitives/schedule.md § Snapshot 不变性) — warn + ack + drop.
|
|
21
|
+
*
|
|
22
|
+
* Reply semantics are uniform with the rest of the connector: NOTHING
|
|
23
|
+
* auto-delivers (the CLI-only reply contract,
|
|
24
|
+
* parel-reply-contract-alignment.md). A typed turn's turn-end text is never
|
|
25
|
+
* posted anywhere; the agent acts through its sandbox CLI — identical to
|
|
26
|
+
* the standard runtimes, where typed events are answered with CLI actions.
|
|
27
|
+
* approval_decided differs only in ROUTING metadata: it lives in a chat, so
|
|
28
|
+
* its plan carries the chatId — that anchors the suppressed turn-end audit
|
|
29
|
+
* step and tells the agent where to `parall messages send`; the reply
|
|
30
|
+
* itself is still an explicit CLI send.
|
|
31
|
+
*/
|
|
32
|
+
export interface TypedDispatchData {
|
|
33
|
+
id?: unknown;
|
|
34
|
+
event_type?: unknown;
|
|
35
|
+
source_type?: unknown;
|
|
36
|
+
source_id?: unknown;
|
|
37
|
+
chat_id?: unknown;
|
|
38
|
+
task_id?: unknown;
|
|
39
|
+
actor_id?: unknown;
|
|
40
|
+
delivery_reason?: unknown;
|
|
41
|
+
}
|
|
42
|
+
/** Dispatch event types this module consumes (everything but the message pair). */
|
|
43
|
+
export declare const TYPED_EVENT_TYPES: readonly ["task_assign", "task_update", "task_comment", "wiki_comment", "schedule.fire", "external_trigger", "approval_decided"];
|
|
44
|
+
export declare function isTypedEventType(eventType: string): boolean;
|
|
45
|
+
/** What the inbound pipeline needs to emit + report one typed event. */
|
|
46
|
+
export interface TypedEventPlan {
|
|
47
|
+
outcome: 'emit';
|
|
48
|
+
/** F2 grouping key + agent-event correlation anchor (task/schedule/… id). */
|
|
49
|
+
subject: string;
|
|
50
|
+
/** Framed event body (the envelope's data.text). */
|
|
51
|
+
body: string;
|
|
52
|
+
/** Input-step routing; null skips the step (no meaningful target). */
|
|
53
|
+
stepTarget: {
|
|
54
|
+
type: string;
|
|
55
|
+
id: string;
|
|
56
|
+
} | null;
|
|
57
|
+
/** Input-step content bits. */
|
|
58
|
+
triggerType: string;
|
|
59
|
+
summary: string;
|
|
60
|
+
/**
|
|
61
|
+
* Set only for approval_decided: anchors the suppressed turn-end audit
|
|
62
|
+
* step and tells the agent where to `parall messages send` — replies are
|
|
63
|
+
* CLI-only, nothing auto-delivers.
|
|
64
|
+
*/
|
|
65
|
+
chatId?: string;
|
|
66
|
+
}
|
|
67
|
+
export type TypedDispatchResult = TypedEventPlan
|
|
68
|
+
/** Permanently unusable — ack and drop. */
|
|
69
|
+
| {
|
|
70
|
+
outcome: 'stale';
|
|
71
|
+
}
|
|
72
|
+
/** Transient failure — leave pending for the catch-up sweep. */
|
|
73
|
+
| {
|
|
74
|
+
outcome: 'pending';
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Build the plan for one typed dispatch. Never throws: any unexpected error
|
|
78
|
+
* degrades to `pending` (same posture as the message path — the sweep
|
|
79
|
+
* retries, and a poisoned row eventually surfaces as a stuck pending count).
|
|
80
|
+
*/
|
|
81
|
+
export declare function planTypedDispatch(data: TypedDispatchData, ctx: ConnectorContext): Promise<TypedDispatchResult>;
|
|
82
|
+
//# sourceMappingURL=typed-dispatch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typed-dispatch.d.ts","sourceRoot":"","sources":["../src/typed-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAI1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,mFAAmF;AACnF,eAAO,MAAM,iBAAiB,kIAQpB,CAAC;AAEX,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAChD,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,mBAAmB,GAC3B,cAAc;AAChB,2CAA2C;GACzC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE;AACtB,gEAAgE;GAC9D;IAAE,OAAO,EAAE,SAAS,CAAA;CAAE,CAAC;AAY3B;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,iBAAiB,EACvB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,mBAAmB,CAAC,CAwC9B"}
|