@parall/parel-channel 1.42.1 → 1.44.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-capability.d.ts +29 -0
- package/dist/channel-capability.d.ts.map +1 -0
- package/dist/channel-capability.js +81 -0
- package/dist/channel-prompt.d.ts +16 -9
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +25 -19
- package/dist/delivery.js +1 -1
- package/dist/fork.d.ts +8 -4
- package/dist/fork.d.ts.map +1 -1
- package/dist/inbound-channel.d.ts.map +1 -1
- package/dist/inbound-channel.js +19 -14
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +65 -43
- package/dist/session.d.ts +43 -19
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +75 -36
- package/package.json +2 -2
- package/src/channel-capability.ts +121 -0
- package/src/channel-prompt.ts +33 -20
- package/src/delivery.ts +1 -1
- package/src/fork.ts +6 -7
- package/src/inbound-channel.ts +21 -15
- package/src/inbound.ts +75 -52
- package/src/session.ts +81 -34
- package/dist/parel-sdk-compat.d.ts +0 -63
- package/dist/parel-sdk-compat.d.ts.map +0 -1
- package/dist/parel-sdk-compat.js +0 -16
- package/src/parel-sdk-compat.ts +0 -77
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-capability credential injection — the parel-native form of the
|
|
3
|
+
* feishu-cli broker (multi-channel-architecture-design §8.3).
|
|
4
|
+
*
|
|
5
|
+
* Bridges deliver the capability as a PATH pointer shim that mints a fresh
|
|
6
|
+
* short-lived tenant token per lark-cli invocation. In parel we control
|
|
7
|
+
* neither the sandbox PATH nor a bridge process — but we DO control the
|
|
8
|
+
* per-turn invocation context, which sandbox-e2b flattens into every exec's
|
|
9
|
+
* env. So the connector mints at emit time and injects the official
|
|
10
|
+
* LARKSUITE_CLI_* env set: bare `lark-cli` works ambiently for the whole
|
|
11
|
+
* turn (turn length ≪ token TTL ~2h), the app secret never leaves the
|
|
12
|
+
* platform, and revocation bites on the next emit's mint (403 → no env).
|
|
13
|
+
*
|
|
14
|
+
* The mint endpoint IS the gate (the platform re-evaluates connection state
|
|
15
|
+
* + flag on every request — the no-drift invariant), so the connector needs
|
|
16
|
+
* no declaration-plane fetch: a 403 is the revocation signal and is
|
|
17
|
+
* negative-cached briefly to keep a revoked grant from adding one 403 per
|
|
18
|
+
* turn.
|
|
19
|
+
*/
|
|
20
|
+
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
21
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
22
|
+
|
|
23
|
+
const MINT_TIMEOUT_MS = 5_000;
|
|
24
|
+
// Re-mint this long before the token expires so an env set injected at turn
|
|
25
|
+
// start stays valid through a long turn's tail.
|
|
26
|
+
const EXPIRY_SKEW_MS = 10 * 60 * 1000;
|
|
27
|
+
// After a 403 (no grant / revoked / flag off), skip minting for this long —
|
|
28
|
+
// revocation latency stays bounded while per-turn 403 noise does not.
|
|
29
|
+
const DENIED_TTL_MS = 60 * 1000;
|
|
30
|
+
// A GRANTED result is re-validated on the same cadence: the mint endpoint IS
|
|
31
|
+
// the revocation gate, so a positive cache lasting the token TTL (~2h) would
|
|
32
|
+
// extend revocation latency to hours. Re-minting is cheap (the platform
|
|
33
|
+
// Redis-caches the exchanged token and re-evaluates the gate per request);
|
|
34
|
+
// this cache only absorbs bursty emits within a conversation.
|
|
35
|
+
const GRANTED_TTL_MS = 60 * 1000;
|
|
36
|
+
|
|
37
|
+
interface MintedToken {
|
|
38
|
+
app_id: string;
|
|
39
|
+
brand: string;
|
|
40
|
+
token: string;
|
|
41
|
+
expires_at: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CacheEntry {
|
|
45
|
+
env: Record<string, string> | null; // null = denied (negative cache)
|
|
46
|
+
validUntil: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Per-connection cache. The plugin isolate is per-connection, so a plain
|
|
50
|
+
// module map keyed by connectionId is effectively single-entry — the key
|
|
51
|
+
// guards against isolate reuse across connections.
|
|
52
|
+
const cache = new Map<string, CacheEntry>();
|
|
53
|
+
|
|
54
|
+
/** Test hook: the cache is module-level state, so suites reset it between cases. */
|
|
55
|
+
export function resetChannelCapabilityCache(): void {
|
|
56
|
+
cache.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function envFromMint(minted: MintedToken): Record<string, string> {
|
|
60
|
+
return {
|
|
61
|
+
LARKSUITE_CLI_APP_ID: minted.app_id,
|
|
62
|
+
LARKSUITE_CLI_BRAND: minted.brand || 'feishu',
|
|
63
|
+
LARKSUITE_CLI_TENANT_ACCESS_TOKEN: minted.token,
|
|
64
|
+
// Bot lock: the credential is the app's, not a human's — mirror the
|
|
65
|
+
// bridge shim's injection exactly (agent-core channel-exec).
|
|
66
|
+
LARKSUITE_CLI_DEFAULT_AS: 'bot',
|
|
67
|
+
LARKSUITE_CLI_STRICT_MODE: 'bot',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns the feishu-cli env set to spread into an envelope's invocation
|
|
73
|
+
* context, or null when the capability is not granted (or the mint degraded
|
|
74
|
+
* — a transient platform error must never block a dispatch emit).
|
|
75
|
+
*/
|
|
76
|
+
export async function channelCapabilityEnv(
|
|
77
|
+
ctx: ConnectorContext,
|
|
78
|
+
): Promise<Record<string, string> | null> {
|
|
79
|
+
const key = ctx.connectionId;
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const hit = cache.get(key);
|
|
82
|
+
if (hit && hit.validUntil > now) return hit.env;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(
|
|
86
|
+
`${parallApiUrl(ctx)}/api/v1/orgs/${encodeURIComponent(parallOrgId(ctx))}/agents/me/channel-token`,
|
|
87
|
+
{
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
authorization: `Bearer ${requireAgk(ctx)}`,
|
|
91
|
+
'content-type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({ channel_type: 'feishu' }),
|
|
94
|
+
signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
if (res.status === 403 || res.status === 404) {
|
|
98
|
+
// No grant (never had it / revoked / flag off) — or an old server
|
|
99
|
+
// without the endpoint. Same agent-facing outcome: no capability.
|
|
100
|
+
cache.set(key, { env: null, validUntil: now + DENIED_TTL_MS });
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
// Transient (5xx/502-mint-upstream): don't cache, don't block the emit.
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const minted = (await res.json()) as MintedToken;
|
|
108
|
+
if (!minted?.token || !minted.app_id) return null;
|
|
109
|
+
const env = envFromMint(minted);
|
|
110
|
+
const expiresAt = Date.parse(minted.expires_at);
|
|
111
|
+
// Bounded by BOTH the revalidation cadence (revocation latency) and the
|
|
112
|
+
// token's own remaining life (an injected env must outlive the turn).
|
|
113
|
+
const tokenBound = Number.isFinite(expiresAt) ? expiresAt - EXPIRY_SKEW_MS : now;
|
|
114
|
+
const validUntil = Math.max(now + 5_000, Math.min(now + GRANTED_TTL_MS, tokenBound));
|
|
115
|
+
cache.set(key, { env, validUntil });
|
|
116
|
+
return env;
|
|
117
|
+
} catch {
|
|
118
|
+
// Network failure — degrade to "no env this turn"; the next emit retries.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/channel-prompt.ts
CHANGED
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
*
|
|
17
17
|
* - The audience is OUTSIDE Parall — no prll:// refs, no internal cards or
|
|
18
18
|
* attachment links in the reply (dead links there).
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* - Single-path reply (multi-channel-architecture-design §6): with the
|
|
20
|
+
* feishu-cli capability granted, the reply goes out through the official
|
|
21
|
+
* lark-cli — auth rides the turn's invocation context env
|
|
22
|
+
* (channel-capability.ts), so bare `lark-cli` works in the sandbox.
|
|
23
|
+
* Without the grant there is no outbound path and the hint says so (the
|
|
24
|
+
* provider clip path is retired).
|
|
25
25
|
* - When the inbound external message id is known, offer the in-thread reply
|
|
26
|
-
* form
|
|
27
|
-
*
|
|
26
|
+
* form so threaded conversations aren't nudged toward new top-level
|
|
27
|
+
* messages.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
function sanitizeMeta(value: string): string {
|
|
@@ -90,7 +90,7 @@ export function buildChatPrompt(args: ChatPromptArgs): string {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export interface ChannelPromptArgs {
|
|
93
|
-
/** Provider name (
|
|
93
|
+
/** Provider name (e.g. "feishu"); undefined when unresolved. */
|
|
94
94
|
provider?: string;
|
|
95
95
|
/** Conversation shape as recorded on the ChannelConversation (dm/group). */
|
|
96
96
|
conversationType?: string;
|
|
@@ -99,6 +99,13 @@ export interface ChannelPromptArgs {
|
|
|
99
99
|
senderName: string;
|
|
100
100
|
/** The inbound message text (raw, un-framed). */
|
|
101
101
|
text: string;
|
|
102
|
+
/**
|
|
103
|
+
* Live capability grant: the turn's invocation context carries the minted
|
|
104
|
+
* LARKSUITE_CLI_* env, so bare `lark-cli` works in the sandbox and is THE
|
|
105
|
+
* reply path. False → outbound is disabled for this org (no grant); the
|
|
106
|
+
* hint says so instead of pointing at the retired provider clip.
|
|
107
|
+
*/
|
|
108
|
+
cliCapable?: boolean;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
export function buildChannelPrompt(args: ChannelPromptArgs): string {
|
|
@@ -122,16 +129,22 @@ export function buildChannelPrompt(args: ChannelPromptArgs): string {
|
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
function buildChannelReplyHint(args: ChannelPromptArgs): string {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (args.
|
|
133
|
-
const
|
|
134
|
-
|
|
132
|
+
// Single-path routing (multi-channel-architecture-design §6/§8.3): with
|
|
133
|
+
// the feishu-cli grant, the minted LARKSUITE_CLI_* env rides this turn's
|
|
134
|
+
// invocation context, so the official lark-cli works ambiently in the
|
|
135
|
+
// sandbox and is THE reply path. Without the grant there is no outbound
|
|
136
|
+
// path — say so instead of pointing at the retired provider clip.
|
|
137
|
+
// cliCapable alone decides: the mint is feishu-specific, so a successful
|
|
138
|
+
// grant implies Feishu even when the cosmetic provider-label lookup failed.
|
|
139
|
+
if (args.cliCapable) {
|
|
140
|
+
const convRef = args.externalConversationId
|
|
141
|
+
? `chat_id "${args.externalConversationId}"`
|
|
142
|
+
: 'the conversation id named in this event';
|
|
143
|
+
const threadAlt = args.externalMessageId
|
|
144
|
+
? ` To reply threaded to this specific message, reference message_id "${args.externalMessageId}".`
|
|
145
|
+
: '';
|
|
146
|
+
return `\n<system-reminder>To reply, use the official Feishu CLI in your sandbox: send a message to ${convRef} with \`lark-cli im\` (see \`lark-cli im --help\` for send syntax; auth is provisioned in your environment — if lark-cli is missing, install it once with \`npm i -g @larksuite/cli\`).${threadAlt} lark-cli is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
|
|
135
147
|
}
|
|
136
|
-
|
|
148
|
+
const platform = args.provider ?? 'the external platform';
|
|
149
|
+
return `\n<system-reminder>This message arrived from ${platform}, but outbound replies are currently disabled for this org (no channel capability granted). Do NOT attempt to reply on the external platform. If action is needed, surface it inside Parall (\`parall messages send\` / \`parall dm\`). Your plain text output is not delivered anywhere.</system-reminder>`;
|
|
137
150
|
}
|
package/src/delivery.ts
CHANGED
|
@@ -99,7 +99,7 @@ export async function buildParallDelivery(
|
|
|
99
99
|
// ON and observe OFF — the platform template always sets both together,
|
|
100
100
|
// so it exists only on a hand-crafted binding.
|
|
101
101
|
if (route.envelopeId) {
|
|
102
|
-
await completeFencedEnvelopes(ctx, [route.envelopeId]);
|
|
102
|
+
await completeFencedEnvelopes(ctx, [route.envelopeId], 'ok');
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const subject = route.chatId ?? route.conversationId;
|
package/src/fork.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
-
|
|
1
|
+
import type { AgentEvent, ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
+
|
|
3
|
+
/** The child-spawn failure variant of the sdk's AgentEvent union. */
|
|
4
|
+
type ChildSpawnFailedEvent = Extract<AgentEvent, { type: 'child_spawn_failed' }>;
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Fork-on-busy scheduling (the parall-side half of parel F4, parel-mono #140;
|
|
@@ -298,10 +300,7 @@ export async function clearForkState(ctx: ConnectorContext): Promise<string[]> {
|
|
|
298
300
|
* Track main-session busyness from the E1 turn stream. Child events (they
|
|
299
301
|
* carry childRef) must NOT feed this — only the main session's turns do.
|
|
300
302
|
*/
|
|
301
|
-
export async function noteMainTurnEvent(
|
|
302
|
-
ctx: ConnectorContext,
|
|
303
|
-
event: AgentEventWithChild,
|
|
304
|
-
): Promise<void> {
|
|
303
|
+
export async function noteMainTurnEvent(ctx: ConnectorContext, event: AgentEvent): Promise<void> {
|
|
305
304
|
if (event.type === 'turn_started') {
|
|
306
305
|
const row: MainTurnRow = { turnId: event.turnId, subject: event.subject, at: ctx.now() };
|
|
307
306
|
await ctx.store?.set(MAIN_TURN_KEY, row).catch(() => {});
|
|
@@ -323,7 +322,7 @@ export async function noteMainTurnEvent(
|
|
|
323
322
|
*/
|
|
324
323
|
export async function settleChildEvent(
|
|
325
324
|
ctx: ConnectorContext,
|
|
326
|
-
event:
|
|
325
|
+
event: AgentEvent,
|
|
327
326
|
): Promise<{ subject: string; pendingAcks: PendingAck[] } | null> {
|
|
328
327
|
const childRef = event.childRef;
|
|
329
328
|
if (!childRef) return null;
|
package/src/inbound-channel.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
|
|
2
|
+
import { channelCapabilityEnv } from './channel-capability.js';
|
|
2
3
|
import { buildChannelPrompt } from './channel-prompt.js';
|
|
3
4
|
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
+
claimDispatchSource,
|
|
7
|
+
completeSourceEffect,
|
|
6
8
|
createChannelInputStep,
|
|
7
9
|
ensureSession,
|
|
8
10
|
invalidateSession,
|
|
9
|
-
markDispatchReceived,
|
|
10
11
|
nextEnvelopeId,
|
|
11
12
|
patchSession,
|
|
12
13
|
readActiveEmit,
|
|
@@ -61,7 +62,7 @@ export async function effectsForChannelDispatch(
|
|
|
61
62
|
if (m.status === 404 || m.status === 403) {
|
|
62
63
|
// Deleted / inaccessible (also the shape a disabled org feature flag
|
|
63
64
|
// takes) — permanently unusable, drop it.
|
|
64
|
-
return [
|
|
65
|
+
return [completeSourceEffect(ctx, 'channel_message', sourceId)];
|
|
65
66
|
}
|
|
66
67
|
return []; // transient: keep pending, the catch-up sweep retries
|
|
67
68
|
}
|
|
@@ -77,19 +78,19 @@ export async function effectsForChannelDispatch(
|
|
|
77
78
|
const text = msg?.text ?? '';
|
|
78
79
|
if (!conversationId) {
|
|
79
80
|
// No conversation → no session key and no reply target: unusable.
|
|
80
|
-
return [
|
|
81
|
+
return [completeSourceEffect(ctx, 'channel_message', sourceId)];
|
|
81
82
|
}
|
|
82
83
|
if (!text) {
|
|
83
84
|
// The ingress adapter only persists mechanically parsed text; a row with
|
|
84
85
|
// none will never grow any (same reasoning as attachment-only chat
|
|
85
86
|
// messages) — drop rather than let it pile up in the pending queue.
|
|
86
|
-
return [
|
|
87
|
+
return [completeSourceEffect(ctx, 'channel_message', sourceId)];
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
const c = await get(`/channel-conversations/${conversationId}`);
|
|
90
91
|
if (!c.ok) {
|
|
91
92
|
if (c.status === 404 || c.status === 403) {
|
|
92
|
-
return [
|
|
93
|
+
return [completeSourceEffect(ctx, 'channel_message', sourceId)];
|
|
93
94
|
}
|
|
94
95
|
return [];
|
|
95
96
|
}
|
|
@@ -114,7 +115,7 @@ export async function effectsForChannelDispatch(
|
|
|
114
115
|
// (see the comments there): received settles before this function returns;
|
|
115
116
|
// the turnEnvelope marker goes down before any session write; the row
|
|
116
117
|
// stays 'received' until the turn event's complete-sources.
|
|
117
|
-
const receivedSettled =
|
|
118
|
+
const receivedSettled = claimDispatchSource(ctx, 'channel_message', sourceId);
|
|
118
119
|
const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
|
|
119
120
|
const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
|
|
120
121
|
let sessionId = await ensureSession(ctx);
|
|
@@ -154,6 +155,10 @@ export async function effectsForChannelDispatch(
|
|
|
154
155
|
await receivedSettled;
|
|
155
156
|
await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId);
|
|
156
157
|
|
|
158
|
+
// Live capability grant → this turn's lark-cli auth env (mint gate = the
|
|
159
|
+
// no-drift revocation point) + the hint teaching the single reply path.
|
|
160
|
+
const capabilityEnv = await channelCapabilityEnv(ctx);
|
|
161
|
+
|
|
157
162
|
return [
|
|
158
163
|
{
|
|
159
164
|
type: 'emitEvent',
|
|
@@ -178,6 +183,7 @@ export async function effectsForChannelDispatch(
|
|
|
178
183
|
externalMessageId,
|
|
179
184
|
senderName,
|
|
180
185
|
text,
|
|
186
|
+
cliCapable: capabilityEnv !== null,
|
|
181
187
|
}),
|
|
182
188
|
conversationId,
|
|
183
189
|
channelMessageId: sourceId,
|
|
@@ -185,19 +191,19 @@ export async function effectsForChannelDispatch(
|
|
|
185
191
|
externalConversationId,
|
|
186
192
|
externalMessageId,
|
|
187
193
|
},
|
|
188
|
-
// No chatId on purpose: the reply
|
|
189
|
-
//
|
|
190
|
-
//
|
|
194
|
+
// No chatId on purpose: the reply is the agent's own lark-cli send,
|
|
195
|
+
// NOT deliver() — deliver only runs the redundant session idle for
|
|
196
|
+
// channel routes (see delivery.ts).
|
|
191
197
|
replyRoute: {
|
|
192
198
|
kind: 'provider_http',
|
|
193
199
|
connectionId: ctx.connectionId,
|
|
194
200
|
data: { conversationId, envelopeId },
|
|
195
201
|
},
|
|
196
|
-
//
|
|
197
|
-
// a chat that doesn't exist
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
202
|
+
// Invocation context carries ONLY the capability env: no PRLL_CHAT_ID
|
|
203
|
+
// (it must not point CLI defaults at a chat that doesn't exist) and
|
|
204
|
+
// no PRLL_DISPATCH_SOURCE_* (the external reply is a vendor-CLI send,
|
|
205
|
+
// and this row's ledger close is the turn event's complete-sources).
|
|
206
|
+
...(capabilityEnv ? { context: capabilityEnv } : {}),
|
|
201
207
|
},
|
|
202
208
|
},
|
|
203
209
|
// No ack effect — see the message path (inbound.ts): the row stays
|
package/src/inbound.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ConnectorEffect,
|
|
6
6
|
WebSocketFrame,
|
|
7
7
|
} from '@parel/plugin-sdk';
|
|
8
|
+
import { channelCapabilityEnv } from './channel-capability.js';
|
|
8
9
|
import { buildChatPrompt, type PromptAttachment } from './channel-prompt.js';
|
|
9
10
|
import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
|
|
10
11
|
import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
|
|
@@ -25,16 +26,11 @@ import {
|
|
|
25
26
|
settleChildEvent,
|
|
26
27
|
trackFollowUpAck,
|
|
27
28
|
} from './fork.js';
|
|
28
|
-
import {
|
|
29
|
-
type AgentEventWithChild,
|
|
30
|
-
asChildSpawnFailed,
|
|
31
|
-
asConnectorEffects,
|
|
32
|
-
type ExtendedConnectorEffect,
|
|
33
|
-
} from './parel-sdk-compat.js';
|
|
34
29
|
import {
|
|
35
30
|
ackByIdEffect,
|
|
36
|
-
|
|
31
|
+
claimDispatchSource,
|
|
37
32
|
completeFencedEnvelopes,
|
|
33
|
+
completeSourceEffect,
|
|
38
34
|
conversationKey,
|
|
39
35
|
createErrorStep,
|
|
40
36
|
fetchMainSessionFresh,
|
|
@@ -46,7 +42,6 @@ import {
|
|
|
46
42
|
ensureSession,
|
|
47
43
|
invalidateChildSession,
|
|
48
44
|
invalidateSession,
|
|
49
|
-
markDispatchReceived,
|
|
50
45
|
nextEnvelopeId,
|
|
51
46
|
patchSession,
|
|
52
47
|
readActiveEmit,
|
|
@@ -400,7 +395,7 @@ async function effectsForDispatch(
|
|
|
400
395
|
if (sourceType !== 'message' || !sourceId) return [];
|
|
401
396
|
if (!chatId) {
|
|
402
397
|
// A message dispatch with no chat can never be routed or replied to.
|
|
403
|
-
return [
|
|
398
|
+
return [completeSourceEffect(ctx, sourceType, sourceId)];
|
|
404
399
|
}
|
|
405
400
|
if (await shouldSkipLiveRow(ctx, sourceType, sourceId)) return [];
|
|
406
401
|
|
|
@@ -416,7 +411,7 @@ async function effectsForDispatch(
|
|
|
416
411
|
if (!m.ok) {
|
|
417
412
|
if (m.status === 404 || m.status === 403) {
|
|
418
413
|
// Deleted message / lost chat access — permanently unusable, drop it.
|
|
419
|
-
return [
|
|
414
|
+
return [completeSourceEffect(ctx, sourceType, sourceId)];
|
|
420
415
|
}
|
|
421
416
|
// Transient failure: keep pending, the catch-up sweep retries.
|
|
422
417
|
return [];
|
|
@@ -449,7 +444,7 @@ async function effectsForDispatch(
|
|
|
449
444
|
// Truly empty messages never grow content — ack-and-drop rather than let
|
|
450
445
|
// them pile up in the pending queue forever. (Attachment-only messages
|
|
451
446
|
// used to take this path; they now ride the attachment lines instead.)
|
|
452
|
-
return [
|
|
447
|
+
return [completeSourceEffect(ctx, sourceType, sourceId)];
|
|
453
448
|
}
|
|
454
449
|
|
|
455
450
|
// Frame the message with its chat of origin: all chats share the agent's
|
|
@@ -496,12 +491,18 @@ async function effectsForDispatch(
|
|
|
496
491
|
// so the server resolves this WorkItem in the reply's own transaction.
|
|
497
492
|
// (Fork turns get it too — their rows are legacy-acked so the resolve is a
|
|
498
493
|
// no-op today, and it wires up for free when fork ledger parity lands.)
|
|
494
|
+
// Channel-capability env (lark-cli auth) rides EVERY turn's invocation
|
|
495
|
+
// context while granted — Feishu workspace ops (docs/calendar/…) are asked
|
|
496
|
+
// for from ordinary Parall chats, not only from channel turns. Null when
|
|
497
|
+
// ungranted or the mint degrades; never blocks the emit.
|
|
498
|
+
const capabilityEnv = await channelCapabilityEnv(ctx);
|
|
499
499
|
const invocationContext: Record<string, string> = {
|
|
500
500
|
PRLL_CHAT_ID: chatId,
|
|
501
501
|
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
502
502
|
PRLL_DISPATCH_SOURCE_TYPE: sourceType,
|
|
503
503
|
PRLL_DISPATCH_SOURCE_ID: sourceId,
|
|
504
504
|
...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
|
|
505
|
+
...(capabilityEnv ?? {}),
|
|
505
506
|
};
|
|
506
507
|
|
|
507
508
|
const envelope = {
|
|
@@ -536,7 +537,7 @@ async function effectsForDispatch(
|
|
|
536
537
|
// Progress reporting lands on the CHILD's ase_ — its turn does the work,
|
|
537
538
|
// and the replyRoute carries childRef so deliver() links the reply to the
|
|
538
539
|
// child session (and idles IT, not main).
|
|
539
|
-
const receivedSettled =
|
|
540
|
+
const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
|
|
540
541
|
const childAse = await ensureChildSession(ctx, decision.childRef);
|
|
541
542
|
if (childAse) {
|
|
542
543
|
const results = await Promise.all([
|
|
@@ -586,18 +587,18 @@ async function effectsForDispatch(
|
|
|
586
587
|
source_id: sourceId,
|
|
587
588
|
chat_id: chatId,
|
|
588
589
|
});
|
|
589
|
-
return
|
|
590
|
-
{ type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS },
|
|
591
|
-
]);
|
|
590
|
+
return [{ type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS }];
|
|
592
591
|
}
|
|
593
|
-
return
|
|
592
|
+
return [
|
|
594
593
|
{ type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
|
|
595
|
-
]
|
|
594
|
+
];
|
|
596
595
|
}
|
|
597
|
-
return
|
|
596
|
+
return [
|
|
598
597
|
{ type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
|
|
599
|
-
|
|
600
|
-
|
|
598
|
+
// Fork-handled rows keep the legacy ack machinery (ledger parity is a
|
|
599
|
+
// declared follow-up); by id — the precise form.
|
|
600
|
+
dispatchId ? ackByIdEffect(ctx, dispatchId) : completeSourceEffect(ctx, sourceType, sourceId),
|
|
601
|
+
];
|
|
601
602
|
}
|
|
602
603
|
|
|
603
604
|
if (decision.mode === 'spawn') {
|
|
@@ -625,7 +626,7 @@ async function effectsForDispatch(
|
|
|
625
626
|
// would leave the message stranded with no timer and no bookkeeping
|
|
626
627
|
// until a reconnect).
|
|
627
628
|
let spawnBlackHoled = false;
|
|
628
|
-
const timerEffects:
|
|
629
|
+
const timerEffects: ConnectorEffect[] = [];
|
|
629
630
|
if (dispatchId) {
|
|
630
631
|
const retryKey = `${FORK_RETRY_PREFIX}${dispatchId}`;
|
|
631
632
|
const attempts = await bumpForkRetryAttempts(ctx, retryKey, {
|
|
@@ -643,7 +644,7 @@ async function effectsForDispatch(
|
|
|
643
644
|
}
|
|
644
645
|
}
|
|
645
646
|
if (!spawnBlackHoled) {
|
|
646
|
-
const receivedSettled =
|
|
647
|
+
const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
|
|
647
648
|
const childAse = await ensureChildSession(ctx, decision.childRef);
|
|
648
649
|
if (childAse) {
|
|
649
650
|
const results = await Promise.all([
|
|
@@ -659,7 +660,14 @@ async function effectsForDispatch(
|
|
|
659
660
|
}
|
|
660
661
|
await recordSpawn(ctx, subject, decision.childRef, { dispatchId, sourceId });
|
|
661
662
|
await receivedSettled;
|
|
662
|
-
|
|
663
|
+
// Known gap: SpawnChildSessionEffect (#140) carries no invocation
|
|
664
|
+
// context, so the fork's OPENING turn runs without the capability env
|
|
665
|
+
// (lark-cli workspace ops would need a follow-up turn — those arrive
|
|
666
|
+
// as deliverTo envelopes, which inherit the full context). Channel
|
|
667
|
+
// replies are unaffected: channel_message dispatches always ride main.
|
|
668
|
+
// Fix belongs parel-side (context on spawnChildSession) — tracked as a
|
|
669
|
+
// parel ask.
|
|
670
|
+
return [
|
|
663
671
|
{
|
|
664
672
|
type: 'spawnChildSession',
|
|
665
673
|
childRef: decision.childRef,
|
|
@@ -667,7 +675,7 @@ async function effectsForDispatch(
|
|
|
667
675
|
subject,
|
|
668
676
|
},
|
|
669
677
|
...timerEffects,
|
|
670
|
-
]
|
|
678
|
+
];
|
|
671
679
|
}
|
|
672
680
|
// fall through: black-holed spawn rides main below.
|
|
673
681
|
}
|
|
@@ -682,7 +690,7 @@ async function effectsForDispatch(
|
|
|
682
690
|
// pending→received transition. It is deliberately NOT gated on the
|
|
683
691
|
// session resolving: a transient session failure must not also cost the
|
|
684
692
|
// indicator.
|
|
685
|
-
const receivedSettled =
|
|
693
|
+
const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
|
|
686
694
|
let sessionId = await ensureSession(ctx);
|
|
687
695
|
if (sessionId) {
|
|
688
696
|
// The guard marker goes down BEFORE any session write: a stale
|
|
@@ -751,7 +759,7 @@ async function effectsForTypedDispatch(
|
|
|
751
759
|
data: DispatchData,
|
|
752
760
|
ctx: ConnectorContext,
|
|
753
761
|
): Promise<ConnectorEffect[]> {
|
|
754
|
-
// No
|
|
762
|
+
// No claimDispatchSource here ON PURPOSE: typed dispatches have no
|
|
755
763
|
// message-level activity indicator (DispatchResolvedData: "typed
|
|
756
764
|
// dispatches have no indicator"), so there is no pending ring to light —
|
|
757
765
|
// the typed input step below is the session panel's timeline entry.
|
|
@@ -761,11 +769,21 @@ async function effectsForTypedDispatch(
|
|
|
761
769
|
// Typed WorkItems ack by id, never by source: sibling rows (a task
|
|
762
770
|
// mutation's task_assign + task_update) share (source_type, source_id),
|
|
763
771
|
// and the source ack would terminally resolve work that was never emitted.
|
|
764
|
-
const ack = () =>
|
|
765
|
-
|
|
772
|
+
const ack = (): ConnectorEffect[] => {
|
|
773
|
+
if (!dispatchId) {
|
|
774
|
+
// No WorkItem id (malformed payload): the by-source form would
|
|
775
|
+
// terminally resolve SIBLING typed rows sharing this source — leave
|
|
776
|
+
// the row pending for the sweep instead.
|
|
777
|
+
console.error(
|
|
778
|
+
`[parel-channel] typed dispatch without id (${sourceType}:${sourceId}) left pending`,
|
|
779
|
+
);
|
|
780
|
+
return [];
|
|
781
|
+
}
|
|
782
|
+
return [ackByIdEffect(ctx, dispatchId)];
|
|
783
|
+
};
|
|
766
784
|
const plan = await planTypedDispatch(data, ctx);
|
|
767
785
|
if (plan.outcome === 'pending') return [];
|
|
768
|
-
if (plan.outcome === 'stale') return
|
|
786
|
+
if (plan.outcome === 'stale') return ack();
|
|
769
787
|
|
|
770
788
|
const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
|
|
771
789
|
let sessionId = await ensureSession(ctx);
|
|
@@ -796,6 +814,16 @@ async function effectsForTypedDispatch(
|
|
|
796
814
|
}
|
|
797
815
|
}
|
|
798
816
|
|
|
817
|
+
// Typed turns carry the capability env too: a task or schedule is exactly
|
|
818
|
+
// where "create the Feishu doc / check the calendar" work is assigned, so
|
|
819
|
+
// the every-turn capability contract covers them (null when ungranted or
|
|
820
|
+
// the mint degrades; never blocks the emit).
|
|
821
|
+
const capabilityEnv = await channelCapabilityEnv(ctx);
|
|
822
|
+
const typedContext: Record<string, string> = {
|
|
823
|
+
...(plan.chatId ? { PRLL_CHAT_ID: plan.chatId, PRLL_TRIGGER_MESSAGE_ID: sourceId } : {}),
|
|
824
|
+
...(capabilityEnv ?? {}),
|
|
825
|
+
};
|
|
826
|
+
|
|
799
827
|
return [
|
|
800
828
|
{
|
|
801
829
|
type: 'emitEvent',
|
|
@@ -822,17 +850,10 @@ async function effectsForTypedDispatch(
|
|
|
822
850
|
? { chatId: plan.chatId, messageId: sourceId, envelopeId }
|
|
823
851
|
: { envelopeId },
|
|
824
852
|
},
|
|
825
|
-
...(
|
|
826
|
-
? {
|
|
827
|
-
context: {
|
|
828
|
-
PRLL_CHAT_ID: plan.chatId,
|
|
829
|
-
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
830
|
-
},
|
|
831
|
-
}
|
|
832
|
-
: {}),
|
|
853
|
+
...(Object.keys(typedContext).length > 0 ? { context: typedContext } : {}),
|
|
833
854
|
},
|
|
834
855
|
},
|
|
835
|
-
ack(),
|
|
856
|
+
...ack(),
|
|
836
857
|
];
|
|
837
858
|
}
|
|
838
859
|
|
|
@@ -932,21 +953,20 @@ async function handleParallAgentEventInner(
|
|
|
932
953
|
event: AgentEvent,
|
|
933
954
|
ctx: ConnectorContext,
|
|
934
955
|
): Promise<AgentEventEffect[]> {
|
|
935
|
-
// spawnChildSession's asynchronous error channel — not turn-scoped
|
|
936
|
-
// regardless of observe scopes
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
if (spawnFailed) {
|
|
956
|
+
// spawnChildSession's asynchronous error channel — not turn-scoped and
|
|
957
|
+
// pushed regardless of observe scopes. Handle it before anything
|
|
958
|
+
// subject-gated.
|
|
959
|
+
if (event.type === 'child_spawn_failed') {
|
|
940
960
|
const effects: AgentEventEffect[] = [];
|
|
941
961
|
try {
|
|
942
|
-
const outcome = await handleChildSpawnFailed(ctx,
|
|
962
|
+
const outcome = await handleChildSpawnFailed(ctx, event);
|
|
943
963
|
if (outcome.cleared) {
|
|
944
964
|
// The spawn's ase_ was already created active with an input step; no
|
|
945
965
|
// child turn will ever emit a terminal event for it — settle it here
|
|
946
966
|
// or the session (and its processing indicator) stays active forever.
|
|
947
|
-
const orphanAse = await ensureChildSession(ctx,
|
|
967
|
+
const orphanAse = await ensureChildSession(ctx, event.childRef);
|
|
948
968
|
if (orphanAse) await patchSession(ctx, orphanAse, { status: 'idle' });
|
|
949
|
-
await invalidateChildSession(ctx,
|
|
969
|
+
await invalidateChildSession(ctx, event.childRef);
|
|
950
970
|
}
|
|
951
971
|
// Re-drive the stranded messages WITHOUT waiting for a reconnect sweep
|
|
952
972
|
// (a healthy long-lived socket never re-runs onOpen): park their
|
|
@@ -971,15 +991,14 @@ async function handleParallAgentEventInner(
|
|
|
971
991
|
}
|
|
972
992
|
return effects;
|
|
973
993
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
return handleChildAgentEvent(withChild, ctx);
|
|
994
|
+
if (event.childRef) {
|
|
995
|
+
return handleChildAgentEvent(event, ctx);
|
|
977
996
|
}
|
|
978
997
|
const chatId = event.subject;
|
|
979
998
|
try {
|
|
980
999
|
// Best-effort busy classifier for fork-on-busy: only MAIN turns feed it
|
|
981
1000
|
// (child events took the branch above).
|
|
982
|
-
await noteMainTurnEvent(ctx,
|
|
1001
|
+
await noteMainTurnEvent(ctx, event).catch(() => {});
|
|
983
1002
|
if (!chatId) return [];
|
|
984
1003
|
if (event.type === 'turn_completed' || event.type === 'turn_failed') {
|
|
985
1004
|
// Ledger close FIRST, fenced per source — deliberately NOT behind the
|
|
@@ -995,7 +1014,11 @@ async function handleParallAgentEventInner(
|
|
|
995
1014
|
// the absorbing turn's envelopeIds and settle here naturally. The
|
|
996
1015
|
// server decides reply broad-cover vs no_action per batch; turn_failed
|
|
997
1016
|
// sweeps the same way with the error step below as the visible record.
|
|
998
|
-
await completeFencedEnvelopes(
|
|
1017
|
+
await completeFencedEnvelopes(
|
|
1018
|
+
ctx,
|
|
1019
|
+
event.envelopeIds,
|
|
1020
|
+
event.type === 'turn_failed' ? 'error' : 'ok',
|
|
1021
|
+
);
|
|
999
1022
|
|
|
1000
1023
|
// Staleness guard: only idle if this event's turn covers the AGENT'S
|
|
1001
1024
|
// latest emitted envelope (the marker is agent-global — all chats share
|
|
@@ -1065,7 +1088,7 @@ async function handleParallAgentEventInner(
|
|
|
1065
1088
|
* turnEnvelope marker or the busy classifier.
|
|
1066
1089
|
*/
|
|
1067
1090
|
async function handleChildAgentEvent(
|
|
1068
|
-
event:
|
|
1091
|
+
event: AgentEvent,
|
|
1069
1092
|
ctx: ConnectorContext,
|
|
1070
1093
|
): Promise<AgentEventEffect[]> {
|
|
1071
1094
|
const childRef = event.childRef ?? '';
|