@parall/parel-channel 1.51.0 → 1.52.1
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/catchup.d.ts +79 -0
- package/dist/catchup.d.ts.map +1 -0
- package/dist/catchup.js +296 -0
- package/dist/channel-prompt.d.ts +2 -0
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +2 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +4 -2
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +12 -6
- package/dist/fork.d.ts +7 -0
- package/dist/fork.d.ts.map +1 -1
- package/dist/fork.js +14 -2
- package/dist/inbound-channel.d.ts.map +1 -1
- package/dist/inbound-channel.js +55 -38
- package/dist/inbound.d.ts +14 -13
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +163 -180
- package/dist/index.js +2 -2
- package/dist/session.d.ts +54 -9
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +106 -19
- package/dist/typed-dispatch.d.ts +12 -6
- package/dist/typed-dispatch.d.ts.map +1 -1
- package/dist/typed-dispatch.js +65 -53
- package/package.json +1 -1
- package/src/catchup.ts +337 -0
- package/src/channel-prompt.ts +3 -0
- package/src/connect.ts +4 -2
- package/src/delivery.ts +12 -5
- package/src/fork.ts +17 -2
- package/src/inbound-channel.ts +59 -44
- package/src/inbound.ts +181 -192
- package/src/index.ts +2 -2
- package/src/session.ts +124 -19
- package/src/typed-dispatch.ts +63 -46
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Incremental, timer-driven dispatch catch-up (execution-model v2 I4:
|
|
4
|
+
* lifecycle hooks are O(1); any O(backlog) work must be a durable, resumable,
|
|
5
|
+
* timer-driven job — parel channel-connector-execution-model.md §2).
|
|
6
|
+
*
|
|
7
|
+
* The 1.48.0 shape — onOpen sweeping up to 40×50 dispatches inline — cannot
|
|
8
|
+
* live under the v2 15s onOpen budget (a backlog would blow the budget, and
|
|
9
|
+
* under first-timeout-terminal semantics every timeout is an epoch rotation:
|
|
10
|
+
* the sweep would convert a reconnect into a rotation storm). Instead:
|
|
11
|
+
*
|
|
12
|
+
* onOpen → reset the sweep state, arm CATCHUP_TIMER_KEY at now (O(1))
|
|
13
|
+
* onTimer → one bounded round per firing:
|
|
14
|
+
* round 0 (phase 'gen'): the New Session generation check — MUST precede
|
|
15
|
+
* any dispatch replay (a rotation that happened while offline would
|
|
16
|
+
* otherwise replay post-reset messages into pre-reset forks), plus the
|
|
17
|
+
* forkDisabled revalidation-latch clear. Best-effort, same posture the
|
|
18
|
+
* onOpen sweep had: a transient failure logs and the sweep proceeds
|
|
19
|
+
* (the cached session id — the generation token — is never dropped).
|
|
20
|
+
* sweep rounds: ONE page of GET /dispatch, items replayed FIFO through
|
|
21
|
+
* the normal dispatch pipeline, then re-arm immediately while pages
|
|
22
|
+
* remain. Cursor lives in ctx.store; it is an optimization, not a
|
|
23
|
+
* correctness source — the per-source attempt fence (shouldSkipLiveRow)
|
|
24
|
+
* makes any re-scan idempotent, so a mid-page bail or a crash simply
|
|
25
|
+
* re-lists the same page and skips what already emitted.
|
|
26
|
+
* drain: a pass that ends (no next_cursor) after emitting anything runs
|
|
27
|
+
* one more pass from the top (rows the cursor walk missed, rows that
|
|
28
|
+
* arrived mid-sweep); the job ends on the first CLEAN pass — no emits,
|
|
29
|
+
* no item failures. Item failures keep the row pending server-side and
|
|
30
|
+
* re-arm the next pass with exponential backoff instead of ending, so
|
|
31
|
+
* a transiently unfetchable source is retried without a reconnect.
|
|
32
|
+
*
|
|
33
|
+
* FIFO across the live socket: while a sweep is in flight, dispatch.new
|
|
34
|
+
* frames are NOT processed inline (they would leapfrog the backlog into the
|
|
35
|
+
* shared main session out of order) — the frame handler just re-arms the
|
|
36
|
+
* timer (armCatchUpEffect) and lets the sweep list the row, which is already
|
|
37
|
+
* durable server-side. That re-arm doubles as the lost-timer self-heal: any
|
|
38
|
+
* new frame revives a stalled sweep. The re-arm honours an in-flight backoff
|
|
39
|
+
* (state.notBefore): a frame arriving while a round is backed off on a 5xx
|
|
40
|
+
* source re-arms at the remaining delay, not 0, so steady traffic against a
|
|
41
|
+
* failing source cannot tight-poll it.
|
|
42
|
+
*
|
|
43
|
+
* Runs identically under the v1 host (flag off): timers and effects work the
|
|
44
|
+
* same there, rounds serialize through the connection hook chain, and the
|
|
45
|
+
* bounded round size keeps each onTimer call small.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CATCHUP_TIMER_KEY = "__parall_catchup__";
|
|
48
|
+
/** The idempotent "make sure the sweep timer is armed" effect (setTimer is
|
|
49
|
+
* keyed — re-arming replaces). Also what the frame handler returns while a
|
|
50
|
+
* sweep is in flight. */
|
|
51
|
+
export declare function armCatchUpEffect(ctx: ConnectorContext, delayMs?: number): ConnectorEffect;
|
|
52
|
+
/**
|
|
53
|
+
* onOpen: O(1) by contract — reset the sweep to a fresh pass (a reconnect is
|
|
54
|
+
* the revalidation point, so the generation check must run again) and arm the
|
|
55
|
+
* timer. All real work happens in runCatchUpRound.
|
|
56
|
+
*/
|
|
57
|
+
export declare function beginCatchUp(ctx: ConnectorContext): Promise<ConnectorEffect[]>;
|
|
58
|
+
/**
|
|
59
|
+
* The frame handler's FIFO gate: while a sweep is in flight (state present),
|
|
60
|
+
* a live dispatch.new must not leapfrog the backlog. Returns the delay (ms)
|
|
61
|
+
* the caller should re-arm the sweep timer with — honouring any in-flight
|
|
62
|
+
* backoff (notBefore) so a live frame cannot reset a backed-off round to fire
|
|
63
|
+
* immediately — or null when no sweep is in flight (process the frame inline).
|
|
64
|
+
* Also marks the pass dirty so the job runs another from-the-top pass before
|
|
65
|
+
* ending. The dirty mark is load-bearing, not just ordering hygiene: a server
|
|
66
|
+
* REDRIVE hint can point at a row BEHIND the sweep cursor (e.g. during the
|
|
67
|
+
* final re-check pass), and deferring the frame consumes that hint — without
|
|
68
|
+
* forcing one more full pass, the job could end cleanly with the row
|
|
69
|
+
* stranded until the next reconnect. Runs inside the serialized chain, so
|
|
70
|
+
* the read-modify-write cannot interleave with a round's own state write.
|
|
71
|
+
*/
|
|
72
|
+
export declare function deferToCatchUp(ctx: ConnectorContext): Promise<number | null>;
|
|
73
|
+
/**
|
|
74
|
+
* One timer round. `planDispatch` is the normal inbound dispatch pipeline
|
|
75
|
+
* (inbound.ts effectsForDispatch), injected to keep this module's imports
|
|
76
|
+
* acyclic. Every path either re-arms the timer or deliberately ends the job.
|
|
77
|
+
*/
|
|
78
|
+
export declare function runCatchUpRound(ctx: ConnectorContext, planDispatch: (data: Record<string, unknown>) => Promise<ConnectorEffect[]>): Promise<ConnectorEffect[]>;
|
|
79
|
+
//# sourceMappingURL=catchup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catchup.d.ts","sourceRoot":"","sources":["../src/catchup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAK3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AA0FtD;;yBAEyB;AACzB,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,EAAE,OAAO,SAAI,GAAG,eAAe,CAEpF;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAGpF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOlF;AAqCD;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,gBAAgB,EACrB,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,GAC1E,OAAO,CAAC,eAAe,EAAE,CAAC,CAgH5B"}
|
package/dist/catchup.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
2
|
+
import { clearForkState, FORK_DISABLED_KEY } from './fork.js';
|
|
3
|
+
import { fetchMainSessionFresh, invalidateChildSession, SESSION_CACHE_KEY } from './session.js';
|
|
4
|
+
/**
|
|
5
|
+
* Incremental, timer-driven dispatch catch-up (execution-model v2 I4:
|
|
6
|
+
* lifecycle hooks are O(1); any O(backlog) work must be a durable, resumable,
|
|
7
|
+
* timer-driven job — parel channel-connector-execution-model.md §2).
|
|
8
|
+
*
|
|
9
|
+
* The 1.48.0 shape — onOpen sweeping up to 40×50 dispatches inline — cannot
|
|
10
|
+
* live under the v2 15s onOpen budget (a backlog would blow the budget, and
|
|
11
|
+
* under first-timeout-terminal semantics every timeout is an epoch rotation:
|
|
12
|
+
* the sweep would convert a reconnect into a rotation storm). Instead:
|
|
13
|
+
*
|
|
14
|
+
* onOpen → reset the sweep state, arm CATCHUP_TIMER_KEY at now (O(1))
|
|
15
|
+
* onTimer → one bounded round per firing:
|
|
16
|
+
* round 0 (phase 'gen'): the New Session generation check — MUST precede
|
|
17
|
+
* any dispatch replay (a rotation that happened while offline would
|
|
18
|
+
* otherwise replay post-reset messages into pre-reset forks), plus the
|
|
19
|
+
* forkDisabled revalidation-latch clear. Best-effort, same posture the
|
|
20
|
+
* onOpen sweep had: a transient failure logs and the sweep proceeds
|
|
21
|
+
* (the cached session id — the generation token — is never dropped).
|
|
22
|
+
* sweep rounds: ONE page of GET /dispatch, items replayed FIFO through
|
|
23
|
+
* the normal dispatch pipeline, then re-arm immediately while pages
|
|
24
|
+
* remain. Cursor lives in ctx.store; it is an optimization, not a
|
|
25
|
+
* correctness source — the per-source attempt fence (shouldSkipLiveRow)
|
|
26
|
+
* makes any re-scan idempotent, so a mid-page bail or a crash simply
|
|
27
|
+
* re-lists the same page and skips what already emitted.
|
|
28
|
+
* drain: a pass that ends (no next_cursor) after emitting anything runs
|
|
29
|
+
* one more pass from the top (rows the cursor walk missed, rows that
|
|
30
|
+
* arrived mid-sweep); the job ends on the first CLEAN pass — no emits,
|
|
31
|
+
* no item failures. Item failures keep the row pending server-side and
|
|
32
|
+
* re-arm the next pass with exponential backoff instead of ending, so
|
|
33
|
+
* a transiently unfetchable source is retried without a reconnect.
|
|
34
|
+
*
|
|
35
|
+
* FIFO across the live socket: while a sweep is in flight, dispatch.new
|
|
36
|
+
* frames are NOT processed inline (they would leapfrog the backlog into the
|
|
37
|
+
* shared main session out of order) — the frame handler just re-arms the
|
|
38
|
+
* timer (armCatchUpEffect) and lets the sweep list the row, which is already
|
|
39
|
+
* durable server-side. That re-arm doubles as the lost-timer self-heal: any
|
|
40
|
+
* new frame revives a stalled sweep. The re-arm honours an in-flight backoff
|
|
41
|
+
* (state.notBefore): a frame arriving while a round is backed off on a 5xx
|
|
42
|
+
* source re-arms at the remaining delay, not 0, so steady traffic against a
|
|
43
|
+
* failing source cannot tight-poll it.
|
|
44
|
+
*
|
|
45
|
+
* Runs identically under the v1 host (flag off): timers and effects work the
|
|
46
|
+
* same there, rounds serialize through the connection hook chain, and the
|
|
47
|
+
* bounded round size keeps each onTimer call small.
|
|
48
|
+
*/
|
|
49
|
+
export const CATCHUP_TIMER_KEY = '__parall_catchup__';
|
|
50
|
+
/** Store key — deliberately OUTSIDE the `fork` prefix family so the
|
|
51
|
+
* agent.new_session purge (clearForkState) never wipes sweep state. */
|
|
52
|
+
const CATCHUP_STATE_KEY = 'catchup';
|
|
53
|
+
/** One page per round; page size == round item bound so a clean round always
|
|
54
|
+
* advances the cursor. Small on purpose: each item can fan out several inline
|
|
55
|
+
* fetches and 1-2 effects, and the round's effects execute while the v2
|
|
56
|
+
* execution slot is held — a big page would occupy it for the whole replay. */
|
|
57
|
+
const CATCHUP_PAGE_LIMIT = 10;
|
|
58
|
+
/** Soft wall-clock bound per round, checked BETWEEN items (an in-flight item
|
|
59
|
+
* finishes). 4s: the binding constraint is the LEGACY (flag-off) host, whose
|
|
60
|
+
* hook budget is 5s and whose timeout discards the round's effects — re-arm
|
|
61
|
+
* included — rather than cancelling the work; staying under it keeps the
|
|
62
|
+
* common case safe on both hosts (v2's onTimer budget is 60s). A single
|
|
63
|
+
* pathological item can still blow past any bound (its own fetch caps are
|
|
64
|
+
* the 5s REQUEST_TIMEOUT_MS); the self-heal for a discarded re-arm is the
|
|
65
|
+
* frame handler's defer path (any live frame re-arms the timer) plus the
|
|
66
|
+
* next reconnect's beginCatchUp. */
|
|
67
|
+
const ROUND_SOFT_BUDGET_MS = 4_000;
|
|
68
|
+
/** Timeout for the job's OWN fetches (dispatch page list, generation check).
|
|
69
|
+
* Tighter than REQUEST_TIMEOUT_MS: on the v1 host these run inside a 5s
|
|
70
|
+
* onTimer budget whose expiry discards the round's effects AFTER the due
|
|
71
|
+
* timer row was already deleted — a single stalled request at the full 5s
|
|
72
|
+
* cap would strand the job with no timer. 3s leaves re-arm margin. */
|
|
73
|
+
const CATCHUP_FETCH_TIMEOUT_MS = 3_000;
|
|
74
|
+
const BACKOFF_BASE_MS = 5_000;
|
|
75
|
+
const BACKOFF_MAX_MS = 5 * 60 * 1000;
|
|
76
|
+
/** Frozen so the store-less fallback (memoryState, written by reference in
|
|
77
|
+
* beginCatchUp) cannot be mutated into a corrupted shared template — every
|
|
78
|
+
* transition already builds a new object via spread. */
|
|
79
|
+
const FRESH_STATE = Object.freeze({
|
|
80
|
+
phase: 'gen',
|
|
81
|
+
cursor: '',
|
|
82
|
+
dirty: false,
|
|
83
|
+
sawFailure: false,
|
|
84
|
+
failures: 0,
|
|
85
|
+
notBefore: 0,
|
|
86
|
+
});
|
|
87
|
+
/** In-memory fallback for store-less contexts (SDK test mocks) — a module
|
|
88
|
+
* global is a discardable cache, never correctness (v2 I4), which is exactly
|
|
89
|
+
* the durability class a fallback may have. */
|
|
90
|
+
const memoryState = new Map();
|
|
91
|
+
async function readState(ctx) {
|
|
92
|
+
if (!ctx.store)
|
|
93
|
+
return memoryState.get(ctx.connectionId) ?? null;
|
|
94
|
+
const raw = (await ctx.store.get(CATCHUP_STATE_KEY).catch(() => null));
|
|
95
|
+
return raw && (raw.phase === 'gen' || raw.phase === 'sweep') ? raw : null;
|
|
96
|
+
}
|
|
97
|
+
async function writeState(ctx, state) {
|
|
98
|
+
if (!ctx.store) {
|
|
99
|
+
memoryState.set(ctx.connectionId, state);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await ctx.store.set(CATCHUP_STATE_KEY, state).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
async function clearState(ctx) {
|
|
105
|
+
if (!ctx.store) {
|
|
106
|
+
memoryState.delete(ctx.connectionId);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await ctx.store.delete(CATCHUP_STATE_KEY).catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
/** The idempotent "make sure the sweep timer is armed" effect (setTimer is
|
|
112
|
+
* keyed — re-arming replaces). Also what the frame handler returns while a
|
|
113
|
+
* sweep is in flight. */
|
|
114
|
+
export function armCatchUpEffect(ctx, delayMs = 0) {
|
|
115
|
+
return { type: 'setTimer', key: CATCHUP_TIMER_KEY, at: ctx.now() + delayMs };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* onOpen: O(1) by contract — reset the sweep to a fresh pass (a reconnect is
|
|
119
|
+
* the revalidation point, so the generation check must run again) and arm the
|
|
120
|
+
* timer. All real work happens in runCatchUpRound.
|
|
121
|
+
*/
|
|
122
|
+
export async function beginCatchUp(ctx) {
|
|
123
|
+
await writeState(ctx, FRESH_STATE);
|
|
124
|
+
return [armCatchUpEffect(ctx)];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The frame handler's FIFO gate: while a sweep is in flight (state present),
|
|
128
|
+
* a live dispatch.new must not leapfrog the backlog. Returns the delay (ms)
|
|
129
|
+
* the caller should re-arm the sweep timer with — honouring any in-flight
|
|
130
|
+
* backoff (notBefore) so a live frame cannot reset a backed-off round to fire
|
|
131
|
+
* immediately — or null when no sweep is in flight (process the frame inline).
|
|
132
|
+
* Also marks the pass dirty so the job runs another from-the-top pass before
|
|
133
|
+
* ending. The dirty mark is load-bearing, not just ordering hygiene: a server
|
|
134
|
+
* REDRIVE hint can point at a row BEHIND the sweep cursor (e.g. during the
|
|
135
|
+
* final re-check pass), and deferring the frame consumes that hint — without
|
|
136
|
+
* forcing one more full pass, the job could end cleanly with the row
|
|
137
|
+
* stranded until the next reconnect. Runs inside the serialized chain, so
|
|
138
|
+
* the read-modify-write cannot interleave with a round's own state write.
|
|
139
|
+
*/
|
|
140
|
+
export async function deferToCatchUp(ctx) {
|
|
141
|
+
const state = await readState(ctx);
|
|
142
|
+
if (!state)
|
|
143
|
+
return null;
|
|
144
|
+
if (!state.dirty)
|
|
145
|
+
await writeState(ctx, { ...state, dirty: true });
|
|
146
|
+
// Honour an in-flight backoff: re-arming the keyed timer at delay 0 would
|
|
147
|
+
// overwrite a backoffDelay-armed round and tight-poll a failing source.
|
|
148
|
+
return Math.max(0, state.notBefore - ctx.now());
|
|
149
|
+
}
|
|
150
|
+
function backoffDelay(failures) {
|
|
151
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_MAX_MS);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* New Session generation check — verbatim semantics of the 1.48.0 onOpen
|
|
155
|
+
* preamble (see the session.ts fetchMainSessionFresh doc): the
|
|
156
|
+
* `agent.new_session` WS push is not replayed, so a reset that happened while
|
|
157
|
+
* disconnected leaves stale fork routing that would deliver post-reset
|
|
158
|
+
* messages into pre-reset children. The main ase_ id IS the generation token.
|
|
159
|
+
*/
|
|
160
|
+
async function runGenerationCheck(ctx) {
|
|
161
|
+
try {
|
|
162
|
+
const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null));
|
|
163
|
+
if (cachedMain) {
|
|
164
|
+
const freshMain = await fetchMainSessionFresh(ctx, { timeoutMs: CATCHUP_FETCH_TIMEOUT_MS });
|
|
165
|
+
if (freshMain && freshMain !== cachedMain) {
|
|
166
|
+
const refs = await clearForkState(ctx);
|
|
167
|
+
for (const ref of refs) {
|
|
168
|
+
await invalidateChildSession(ctx, ref);
|
|
169
|
+
}
|
|
170
|
+
console.warn('[parel-channel] main session rotated while offline — fork state purged');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Capability revalidation: a parked forkDisabled latch reflects the
|
|
174
|
+
// binding shape at failure time; the fleet sweep converges bindings
|
|
175
|
+
// without restarting the connection, so a reconnect is the natural
|
|
176
|
+
// revalidation point. If forking is truly still disabled, the first
|
|
177
|
+
// spawn attempt re-parks the latch at the cost of one failed effect.
|
|
178
|
+
await ctx.store?.delete(FORK_DISABLED_KEY).catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
console.error('[parel-channel] new-session generation check failed', err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* One timer round. `planDispatch` is the normal inbound dispatch pipeline
|
|
186
|
+
* (inbound.ts effectsForDispatch), injected to keep this module's imports
|
|
187
|
+
* acyclic. Every path either re-arms the timer or deliberately ends the job.
|
|
188
|
+
*/
|
|
189
|
+
export async function runCatchUpRound(ctx, planDispatch) {
|
|
190
|
+
// A missing state row (timer armed by a deferred frame racing the sweep's
|
|
191
|
+
// clean finish, or state lost) starts a fresh sweep — over-sweeping is
|
|
192
|
+
// idempotent (fences), under-sweeping loses messages.
|
|
193
|
+
const state = (await readState(ctx)) ?? FRESH_STATE;
|
|
194
|
+
if (state.phase === 'gen') {
|
|
195
|
+
await runGenerationCheck(ctx);
|
|
196
|
+
await writeState(ctx, { ...FRESH_STATE, phase: 'sweep' });
|
|
197
|
+
return [armCatchUpEffect(ctx)];
|
|
198
|
+
}
|
|
199
|
+
const roundStart = ctx.now();
|
|
200
|
+
let body = null;
|
|
201
|
+
try {
|
|
202
|
+
const apiUrl = parallApiUrl(ctx);
|
|
203
|
+
const agk = requireAgk(ctx);
|
|
204
|
+
const orgId = parallOrgId(ctx);
|
|
205
|
+
const url = `${apiUrl}/api/v1/orgs/${orgId}/dispatch?limit=${CATCHUP_PAGE_LIMIT}${state.cursor ? `&cursor=${encodeURIComponent(state.cursor)}` : ''}`;
|
|
206
|
+
const res = await fetch(url, {
|
|
207
|
+
headers: { Authorization: `Bearer ${agk}` },
|
|
208
|
+
signal: AbortSignal.timeout(CATCHUP_FETCH_TIMEOUT_MS),
|
|
209
|
+
});
|
|
210
|
+
if (res.ok) {
|
|
211
|
+
body = (await res.json().catch(() => null));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// fall through to the transient-failure backoff below
|
|
216
|
+
}
|
|
217
|
+
if (!body) {
|
|
218
|
+
const failures = state.failures + 1;
|
|
219
|
+
const delay = backoffDelay(failures);
|
|
220
|
+
// Persist notBefore so a live frame's defer re-arm honours the backoff
|
|
221
|
+
// instead of resetting it to fire now (tight-polling the failing API).
|
|
222
|
+
await writeState(ctx, { ...state, failures, notBefore: ctx.now() + delay });
|
|
223
|
+
console.error(`[parel-channel] catch-up page fetch failed (attempt ${failures})`);
|
|
224
|
+
return [armCatchUpEffect(ctx, delay)];
|
|
225
|
+
}
|
|
226
|
+
// Replay SEQUENTIALLY, in page order (FIFO): every envelope feeds the
|
|
227
|
+
// agent's one main session, so cross-chat replay concurrency would
|
|
228
|
+
// scramble the session's input order AND race the agent-global
|
|
229
|
+
// turnEnvelope marker. Transient item failures THROW (the pipeline's
|
|
230
|
+
// transient-vs-deliberate contract: a 5xx/timeout on a source fetch
|
|
231
|
+
// throws; deliberate pendings — unknown types, fence skips, disabled
|
|
232
|
+
// deployments — return []): the row stays pending server-side and the
|
|
233
|
+
// failure flag re-arms the next pass with backoff instead of ending the
|
|
234
|
+
// job with the row stranded on a healthy long-lived socket.
|
|
235
|
+
const rows = body.data ?? [];
|
|
236
|
+
const effects = [];
|
|
237
|
+
let dirty = state.dirty;
|
|
238
|
+
let sawFailure = state.sawFailure;
|
|
239
|
+
let processed = 0;
|
|
240
|
+
for (const d of rows) {
|
|
241
|
+
if (ctx.now() - roundStart > ROUND_SOFT_BUDGET_MS)
|
|
242
|
+
break;
|
|
243
|
+
const out = await planDispatch(d).catch((err) => {
|
|
244
|
+
console.error('[parel-channel] catch-up dispatch failed', err);
|
|
245
|
+
sawFailure = true;
|
|
246
|
+
dirty = true;
|
|
247
|
+
return [];
|
|
248
|
+
});
|
|
249
|
+
// Only MAIN-path emits drive the drain re-check: they arm a fence, so
|
|
250
|
+
// the follow-up pass skips them and the job converges. Fork-owned
|
|
251
|
+
// outputs (spawnChildSession / deliverTo emits) never write a fence —
|
|
252
|
+
// counting them would re-check forever, and their fast re-plan would
|
|
253
|
+
// race the fork verification cadence — their loss recovery belongs to
|
|
254
|
+
// the fork machinery (parked acks, retry timer), not this job.
|
|
255
|
+
if (out.some((e) => e.type === 'emitEvent' && !e.deliverTo)) {
|
|
256
|
+
dirty = true;
|
|
257
|
+
}
|
|
258
|
+
effects.push(...out);
|
|
259
|
+
processed++;
|
|
260
|
+
}
|
|
261
|
+
if (processed < rows.length) {
|
|
262
|
+
// Soft budget hit mid-page: keep the cursor (the refetch re-lists this
|
|
263
|
+
// page; already-emitted rows fence-skip) and continue immediately. The
|
|
264
|
+
// failure streak is NOT reset mid-pass — only a failure-free pass end
|
|
265
|
+
// clears it, or a persistent 5xx in a multi-page backlog would compute
|
|
266
|
+
// a streak of 1 forever and rescan at the base delay instead of backing
|
|
267
|
+
// off. The re-arm timer leads the effect list: v2 replays effects in
|
|
268
|
+
// order and a dead-lettered earlier effect must not also cost the job
|
|
269
|
+
// its progression timer (setTimer is host-owned/idempotent — order is
|
|
270
|
+
// free on v1).
|
|
271
|
+
// Progress this round → no backoff: clear notBefore so a lingering value
|
|
272
|
+
// from an earlier failed round can't make a defer honour a stale backoff.
|
|
273
|
+
await writeState(ctx, { ...state, dirty, sawFailure, notBefore: 0 });
|
|
274
|
+
return [armCatchUpEffect(ctx), ...effects];
|
|
275
|
+
}
|
|
276
|
+
if (body.next_cursor) {
|
|
277
|
+
await writeState(ctx, { ...state, cursor: body.next_cursor, dirty, sawFailure, notBefore: 0 });
|
|
278
|
+
return [armCatchUpEffect(ctx), ...effects];
|
|
279
|
+
}
|
|
280
|
+
// Pass complete. Clean pass (nothing emitted, nothing failed) ends the job;
|
|
281
|
+
// a dirty pass runs another full pass from the top — the re-check that
|
|
282
|
+
// makes the cursor an optimization instead of a correctness source.
|
|
283
|
+
if (!dirty) {
|
|
284
|
+
await clearState(ctx);
|
|
285
|
+
return effects;
|
|
286
|
+
}
|
|
287
|
+
const failures = sawFailure ? state.failures + 1 : 0;
|
|
288
|
+
const delay = sawFailure ? backoffDelay(failures) : 0;
|
|
289
|
+
await writeState(ctx, {
|
|
290
|
+
...FRESH_STATE,
|
|
291
|
+
phase: 'sweep',
|
|
292
|
+
failures,
|
|
293
|
+
notBefore: delay > 0 ? ctx.now() + delay : 0,
|
|
294
|
+
});
|
|
295
|
+
return [armCatchUpEffect(ctx, delay), ...effects];
|
|
296
|
+
}
|
package/dist/channel-prompt.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface ChatPromptArgs {
|
|
|
43
43
|
text: string;
|
|
44
44
|
/** Message attachments — rendered as reference lines (agent fetches via CLI). */
|
|
45
45
|
attachments?: PromptAttachment[];
|
|
46
|
+
/** message.hints.no_reply — rendered as the same [Hint: no_reply] line agent-core injects. */
|
|
47
|
+
noReply?: boolean;
|
|
46
48
|
}
|
|
47
49
|
/**
|
|
48
50
|
* Frame a Parall chat message with its conversation of origin. Kept to a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AASH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,WAAW,CAAC,EAAE,gBAAgB,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AASH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,WAAW,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACjC,8FAA8F;IAC9F,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAmBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAW5D;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkBlE"}
|
package/dist/channel-prompt.js
CHANGED
|
@@ -63,6 +63,8 @@ export function buildChatPrompt(args) {
|
|
|
63
63
|
lines.push(`[From: ${sanitizeMeta(args.senderId)}]`);
|
|
64
64
|
if (args.threadRootId)
|
|
65
65
|
lines.push(`[Thread: ${sanitizeMeta(args.threadRootId)}]`);
|
|
66
|
+
if (args.noReply)
|
|
67
|
+
lines.push(`[Hint: no_reply]`);
|
|
66
68
|
lines.push(...attachmentLines(args.attachments));
|
|
67
69
|
lines.push('', args.text);
|
|
68
70
|
return lines.join('\n');
|
package/dist/connect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,CA2BlF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAE7D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAMxD"}
|
package/dist/connect.js
CHANGED
|
@@ -12,11 +12,13 @@ export async function connectParall(ctx) {
|
|
|
12
12
|
const apiUrl = parallApiUrl(ctx);
|
|
13
13
|
const agk = requireAgk(ctx);
|
|
14
14
|
// connect() runs on every (re)connect — fail fast rather than hang the host's
|
|
15
|
-
// reconnect loop on a wedged ticket endpoint.
|
|
15
|
+
// reconnect loop on a wedged ticket endpoint. 5s: the v2 connect budget is
|
|
16
|
+
// 15s and first-timeout rotates the epoch, so the single mint fetch must
|
|
17
|
+
// leave ample margin (session.ts REQUEST_TIMEOUT_MS rationale).
|
|
16
18
|
const res = await fetch(`${apiUrl}/api/v1/ws/ticket`, {
|
|
17
19
|
method: 'POST',
|
|
18
20
|
headers: { Authorization: `Bearer ${agk}` },
|
|
19
|
-
signal: AbortSignal.timeout(
|
|
21
|
+
signal: AbortSignal.timeout(5_000),
|
|
20
22
|
});
|
|
21
23
|
if (!res.ok) {
|
|
22
24
|
throw new Error(`parel-channel: ws ticket failed (${res.status})`);
|
package/dist/delivery.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAc5F;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAsF5B"}
|
package/dist/delivery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { childRefKnown } from './fork.js';
|
|
2
|
-
import { completeFencedEnvelopes, createSuppressedTextStep, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, patchSession, TURN_ENVELOPE_KEY, } from './session.js';
|
|
2
|
+
import { completeFencedEnvelopes, createSuppressedTextStep, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, patchSession, TURN_ENVELOPE_KEY, turnEventsObserved, } from './session.js';
|
|
3
3
|
/**
|
|
4
4
|
* Turn-end delivery — which, under the platform reply contract, delivers
|
|
5
5
|
* NOTHING to the chat.
|
|
@@ -67,16 +67,22 @@ export async function buildParallDelivery(delivery, ctx) {
|
|
|
67
67
|
// (the npm ^1 fallback path): there turn events never arrive and, with
|
|
68
68
|
// ack-on-emit gone, this hook is the only terminal ledger path — without it
|
|
69
69
|
// a delivered turn's row would stay live and re-emit on every TTL sweep.
|
|
70
|
-
// Under the normal artifact path both hooks run; the fence makes the
|
|
71
|
-
// overlap free (whichever completes first consumes it, the other skips).
|
|
72
70
|
// Fork-handled envelopes never wrote a fence, so this is a no-op for them
|
|
73
71
|
// (their ack machinery owns the ledger there).
|
|
74
|
-
//
|
|
75
|
-
//
|
|
72
|
+
// On an OBSERVING binding this close must stand down (the turn-observed
|
|
73
|
+
// latch): the per-envelope fence makes the overlap idempotent, but not
|
|
74
|
+
// batch-safe — deliver runs before turn_completed, so closing the
|
|
75
|
+
// trigger's envelope here consumes its fence and splits the folded turn's
|
|
76
|
+
// [trigger, sibling] batch; the sibling's complete then finds no covering
|
|
77
|
+
// reply and sweeps to no_action (R-06). Cost of standing down: if the
|
|
78
|
+
// observing binding's turn_completed is lost, the fence heals via the TTL
|
|
79
|
+
// re-emit — the same recovery every lost close already relies on.
|
|
80
|
+
// Known bound of the no-observe fallback: the replyRoute snapshot carries
|
|
81
|
+
// ONE envelope id, so a folded turn's OTHER sources heal only via the TTL
|
|
76
82
|
// re-emit here. Reaching that state needs a binding with injectInFlight
|
|
77
83
|
// ON and observe OFF — the platform template always sets both together,
|
|
78
84
|
// so it exists only on a hand-crafted binding.
|
|
79
|
-
if (route.envelopeId) {
|
|
85
|
+
if (route.envelopeId && !(await turnEventsObserved(ctx))) {
|
|
80
86
|
await completeFencedEnvelopes(ctx, [route.envelopeId], 'ok');
|
|
81
87
|
}
|
|
82
88
|
const subject = route.chatId ?? route.conversationId;
|
package/dist/fork.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ type ChildSpawnFailedEvent = Extract<AgentEvent, {
|
|
|
6
6
|
/** Delay before a failed spawn's messages are re-driven through onTimer. */
|
|
7
7
|
export declare const FORK_RETRY_DELAY_MS = 15000;
|
|
8
8
|
export declare const FORK_RETRY_PREFIX = "forkRetry:";
|
|
9
|
+
/** Exported for the catch-up job's reconnect revalidation (catchup.ts). */
|
|
10
|
+
export declare const FORK_DISABLED_KEY = "forkDisabled";
|
|
9
11
|
export interface PendingAck {
|
|
10
12
|
dispatchId: string;
|
|
11
13
|
sourceId: string;
|
|
@@ -85,6 +87,11 @@ export declare function clearForkSubject(ctx: ConnectorContext, subject: string,
|
|
|
85
87
|
/**
|
|
86
88
|
* Bump-and-park a fork retry ledger row (also the spawn-verification
|
|
87
89
|
* coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
|
|
90
|
+
* Attempts count VERIFICATION ROUNDS on the retry timer's cadence, so a bump
|
|
91
|
+
* younger than FORK_RETRY_DELAY_MS is a fast replay (catch-up re-check pass,
|
|
92
|
+
* rapid reconnect double-sweep) and keeps the prior count — without the
|
|
93
|
+
* floor, back-to-back replays would burn through MAX_SPAWN_VERIFY_ATTEMPTS
|
|
94
|
+
* in milliseconds and black-hole a child that was starting up fine.
|
|
88
95
|
*/
|
|
89
96
|
export declare function bumpForkRetryAttempts(ctx: ConnectorContext, retryKey: string, data: Record<string, unknown>): Promise<number>;
|
|
90
97
|
/** Give up re-verifying a spawn after this many timer rounds. */
|
package/dist/fork.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fork.d.ts","sourceRoot":"","sources":["../src/fork.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAEtE,qEAAqE;AACrE,KAAK,qBAAqB,GAAG,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE,CAAC,CAAC;AA+CjF,4EAA4E;AAC5E,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAS1C,eAAO,MAAM,iBAAiB,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"fork.d.ts","sourceRoot":"","sources":["../src/fork.ts"],"names":[],"mappings":"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"}
|
package/dist/fork.js
CHANGED
|
@@ -47,7 +47,8 @@ const MAX_PENDING_FOLLOWUPS = 20;
|
|
|
47
47
|
const MAX_ACK_ATTEMPTS = 3;
|
|
48
48
|
const MAIN_TURN_KEY = 'forkMainTurn';
|
|
49
49
|
export const FORK_RETRY_PREFIX = 'forkRetry:';
|
|
50
|
-
|
|
50
|
+
/** Exported for the catch-up job's reconnect revalidation (catchup.ts). */
|
|
51
|
+
export const FORK_DISABLED_KEY = 'forkDisabled';
|
|
51
52
|
const childKey = (subject) => `forkChild:${subject}`;
|
|
52
53
|
const childSubjectKey = (childRef) => `forkChildSubject:${childRef}`;
|
|
53
54
|
function isLive(at, ttl, now) {
|
|
@@ -159,11 +160,22 @@ export async function clearForkSubject(ctx, subject, childRef) {
|
|
|
159
160
|
/**
|
|
160
161
|
* Bump-and-park a fork retry ledger row (also the spawn-verification
|
|
161
162
|
* coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
|
|
163
|
+
* Attempts count VERIFICATION ROUNDS on the retry timer's cadence, so a bump
|
|
164
|
+
* younger than FORK_RETRY_DELAY_MS is a fast replay (catch-up re-check pass,
|
|
165
|
+
* rapid reconnect double-sweep) and keeps the prior count — without the
|
|
166
|
+
* floor, back-to-back replays would burn through MAX_SPAWN_VERIFY_ATTEMPTS
|
|
167
|
+
* in milliseconds and black-hole a child that was starting up fine.
|
|
162
168
|
*/
|
|
163
169
|
export async function bumpForkRetryAttempts(ctx, retryKey, data) {
|
|
164
170
|
const prior = (await ctx.store?.get(retryKey).catch(() => null));
|
|
171
|
+
const now = ctx.now();
|
|
172
|
+
if (typeof prior?.attempts === 'number' &&
|
|
173
|
+
typeof prior.at === 'number' &&
|
|
174
|
+
now - prior.at < FORK_RETRY_DELAY_MS) {
|
|
175
|
+
return prior.attempts;
|
|
176
|
+
}
|
|
165
177
|
const attempts = (prior?.attempts ?? 0) + 1;
|
|
166
|
-
await ctx.store?.set(retryKey, { ...data, attempts }).catch(() => { });
|
|
178
|
+
await ctx.store?.set(retryKey, { ...data, attempts, at: now }).catch(() => { });
|
|
167
179
|
return attempts;
|
|
168
180
|
}
|
|
169
181
|
/** Give up re-verifying a spawn after this many timer rounds. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAkB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAkB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAwL5B"}
|