shraga 0.1.87 → 0.1.89
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/README.md +2 -0
- package/dist/client/assets/{index-BSvY0squ.js → index-B-8NBGtJ.js} +38 -38
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/cli.ts +28 -51
- package/src/client/components/ConversationHeader.tsx +54 -11
- package/src/client/components/ConversationPane.tsx +8 -1
- package/src/client/lib/ws.ts +1 -1
- package/src/server/boot.ts +13 -7
- package/src/server/claude.ts +14 -3
- package/src/server/data-sync.ts +26 -2
- package/src/server/engine/claude-code.ts +27 -4
- package/src/server/engine/index.ts +20 -8
- package/src/server/notify-owners.ts +7 -6
- package/src/server/scheduler/builtins.ts +15 -1
- package/src/server/scheduler/runner.ts +5 -4
- package/src/server/sessions.ts +11 -3
- package/src/server/webhook-lane/feature.ts +227 -0
- package/src/server/{para → webhook-lane}/streamer.ts +90 -60
- package/src/server/para/feature.ts +0 -229
|
@@ -219,10 +219,11 @@ export async function runSchedule(
|
|
|
219
219
|
// task.engine/task.model ride the same prompt-directive channel users type by hand —
|
|
220
220
|
// parseDirectives strips them and resolves aliases. Prepending (vs new plumbing) also persists the
|
|
221
221
|
// choice into the saved prompt, so the session UI shows what the schedule actually requested.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
222
|
+
// Applied on RESUME too: a resume's prompt is a fresh "continue where you left off" string, not the
|
|
223
|
+
// saved original, so skipping this here silently dropped the schedule's engine/model pin and let the
|
|
224
|
+
// resumed turn run on the global default — a different engine, and a different vendor's bill.
|
|
225
|
+
const pins = [task.engine && `engine:${task.engine}`, task.model && `model:${task.model}`].filter(Boolean);
|
|
226
|
+
if (pins.length) prompt = `[${pins.join(',')}] ${prompt}`;
|
|
226
227
|
|
|
227
228
|
// Save the synthesized user prompt to the conversation (skip on resume — task prompt already persisted).
|
|
228
229
|
if (!resume) {
|
package/src/server/sessions.ts
CHANGED
|
@@ -38,6 +38,9 @@ export interface SessionMeta {
|
|
|
38
38
|
directives?: Directives;
|
|
39
39
|
/** Actual model the engine resolved at runtime (from the SDK init message) — ground truth, unlike directives.model which is the request. */
|
|
40
40
|
lastModel?: string;
|
|
41
|
+
/** Engine that actually ran the last turn — ground truth beside `lastModel`, so the UI can report
|
|
42
|
+
* what executed instead of inferring the engine from the model id's shape. */
|
|
43
|
+
lastEngine?: string;
|
|
41
44
|
forkedFrom?: string;
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -189,19 +192,24 @@ export function setSessionDirectives(sessionId: string, directives: NonNullable<
|
|
|
189
192
|
// Resolved model per running session (set by the engine from the SDK init message).
|
|
190
193
|
// appendMessage stamps assistant messages from this map so every channel records it.
|
|
191
194
|
const liveModels = new Map<string, string>();
|
|
195
|
+
const liveEngines = new Map<string, string>();
|
|
192
196
|
|
|
193
197
|
/** Last resolved model for a session (live map first, then persisted lastModel). */
|
|
194
198
|
export function getSessionModel(sessionId: string): string | undefined {
|
|
195
199
|
return liveModels.get(sessionId) ?? loadIndex().find((s) => s.sessionId === sessionId)?.lastModel;
|
|
196
200
|
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
/** Record the runtime ground truth for a turn. `engine` is the engine that actually ran it — the
|
|
203
|
+
* pair is what the header reports, so neither half may be inferred from the other. */
|
|
204
|
+
export function setSessionModel(sessionId: string, model: string, engine?: string): void {
|
|
205
|
+
if (liveModels.get(sessionId) === model && (!engine || liveEngines.get(sessionId) === engine)) return;
|
|
200
206
|
liveModels.set(sessionId, model);
|
|
207
|
+
if (engine) liveEngines.set(sessionId, engine);
|
|
201
208
|
const sessions = loadIndex();
|
|
202
209
|
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
203
|
-
if (s && s.lastModel !== model) {
|
|
210
|
+
if (s && (s.lastModel !== model || (engine && s.lastEngine !== engine))) {
|
|
204
211
|
s.lastModel = model;
|
|
212
|
+
if (engine) s.lastEngine = engine;
|
|
205
213
|
saveIndex(sessions);
|
|
206
214
|
}
|
|
207
215
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook lane — the public extension point for wiring an EXTERNAL CHAT PRODUCT to this agent.
|
|
3
|
+
*
|
|
4
|
+
* The shape of the problem, name-neutrally: some other product holds the conversation UI. It wants
|
|
5
|
+
* to hand this agent one turn and render the answer as it is produced. It cannot hold an HTTP
|
|
6
|
+
* response open for the minutes an agent run takes, and it needs the answer authenticated when it
|
|
7
|
+
* comes back. So the lane is TWO independent requests:
|
|
8
|
+
*
|
|
9
|
+
* 1. INGRESS — the product POSTs one turn to a route THIS module mounts for you.
|
|
10
|
+
* 2. CALLBACK — this agent POSTs the answer back, progressively, to a URL the product supplied
|
|
11
|
+
* IN that turn, each delivery HMAC-signed with a secret the product supplied too.
|
|
12
|
+
*
|
|
13
|
+
* The core registers NOTHING here, exactly as in `features.ts`. It ships the machinery — the signed
|
|
14
|
+
* transport, the flush/coalescing policy, the segment wire shape, and the turn-ingress contract —
|
|
15
|
+
* and an add-on names its own product:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { registerFeature } from 'shraga/server/features';
|
|
19
|
+
* import { createWebhookLaneFeature } from 'shraga/server/webhook-lane/feature';
|
|
20
|
+
*
|
|
21
|
+
* registerFeature(createWebhookLaneFeature({
|
|
22
|
+
* name: 'acme',
|
|
23
|
+
* route: '/api/acme/turn',
|
|
24
|
+
* onTurnAccepted: (link) => rememberLink(link), // your persistence, your policy
|
|
25
|
+
* }));
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* WHAT THE ADD-ON OWNS, and why the core cannot: where links are persisted and under what filename,
|
|
29
|
+
* which links may receive PROACTIVE posts (an owner-notice policy is a product decision — see
|
|
30
|
+
* `postNotice` in `streamer.ts` for the transport), the route path, and any CLI surface. None of
|
|
31
|
+
* that is generalizable without inventing policy, so the seam hands it back.
|
|
32
|
+
*
|
|
33
|
+
* TRUST. The ingress is authenticated with an ordinary shraga API key (`POST /api/api-keys`), so
|
|
34
|
+
* reaching the route requires a credential the owner minted. The callback URL + secret arrive IN
|
|
35
|
+
* that authenticated request — per turn — which is what makes a rotated webhook secret take effect
|
|
36
|
+
* on the very next message with no configuration on this side. The URL is still validated here
|
|
37
|
+
* (https, or loopback for development) rather than trusted because the request authenticated: we
|
|
38
|
+
* hold the owner's credential and will POST to whatever it names.
|
|
39
|
+
*/
|
|
40
|
+
import crypto from 'node:crypto';
|
|
41
|
+
import type { ServerFeature, FeatureContext } from '../features.ts';
|
|
42
|
+
import { streamChat } from '../claude.ts';
|
|
43
|
+
import { getMcpConfig } from '../mcp.ts';
|
|
44
|
+
import {
|
|
45
|
+
appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
|
|
46
|
+
type ConvBlock,
|
|
47
|
+
} from '../sessions.ts';
|
|
48
|
+
import { validateApiKey } from '../api-keys.ts';
|
|
49
|
+
import { WebhookStreamer, type WebhookTarget } from './streamer.ts';
|
|
50
|
+
|
|
51
|
+
/** One receiver connection, as learned from a turn. Handed to `onTurnAccepted` so an add-on can
|
|
52
|
+
* persist it and later reach the same conversation proactively.
|
|
53
|
+
*
|
|
54
|
+
* `uid` is the shraga user whose API key opened this link, and `email` is that user's address,
|
|
55
|
+
* taken from `validateApiKey` and never from the request body. An add-on that fans PROACTIVE
|
|
56
|
+
* notices out to "owners" must join on the email — a uid does not join to an owner list. */
|
|
57
|
+
export type WebhookLaneLink = WebhookTarget & { convId: string; at: number; uid: string; email?: string };
|
|
58
|
+
|
|
59
|
+
export interface WebhookLaneOptions {
|
|
60
|
+
/** Feature name (must be unique in the registry). Also the default transcript `channel` and
|
|
61
|
+
* turn-context `source` tag, so a one-line registration is coherent. */
|
|
62
|
+
name: string;
|
|
63
|
+
/** Absolute ingress path to mount, e.g. `/api/acme/turn`. Named by the add-on: the core must not
|
|
64
|
+
* contain a product's route. */
|
|
65
|
+
route: string;
|
|
66
|
+
/** Transcript channel tag stamped on the user message. Defaults to `name`. */
|
|
67
|
+
channel?: string;
|
|
68
|
+
/** `context.source` handed to the turn-context seam. Defaults to `name`. */
|
|
69
|
+
source?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Called once per ACCEPTED turn, before the run starts, with the connection this turn negotiated.
|
|
72
|
+
* This is the add-on's chance to persist the link for its proactive lane.
|
|
73
|
+
*
|
|
74
|
+
* NOTIFICATION, NOT A GATE — and deliberately so. Whether a `connId` may be re-pointed at a
|
|
75
|
+
* different user's callback is the add-on's model, so the add-on refuses the WRITE inside this
|
|
76
|
+
* hook; the turn itself still runs and still answers on the callback the caller supplied, because
|
|
77
|
+
* that caller authenticated with their own API key and is entitled to their own answer. Throwing
|
|
78
|
+
* is contained: a broken persistence layer must not cost the user their reply.
|
|
79
|
+
*/
|
|
80
|
+
onTurnAccepted?(link: WebhookLaneLink): void;
|
|
81
|
+
/** Streamer knobs, if the defaults in `streamer.ts` do not suit the receiver. */
|
|
82
|
+
streamer?: { flushInterval?: number; flushThreshold?: number; postTimeout?: number };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The ingress body, as it arrives on the wire. This IS the contract a receiver implements. */
|
|
86
|
+
export interface TurnRequestBody {
|
|
87
|
+
/** Stable id for the receiver connection. Inside the signed material of every callback. */
|
|
88
|
+
connId?: string;
|
|
89
|
+
/** The receiver's conversation id. Echoed on every callback. */
|
|
90
|
+
convId?: string;
|
|
91
|
+
/** Agent session to run in. Defaults to `convId`, so a receiver may omit it entirely. */
|
|
92
|
+
sessionId?: string;
|
|
93
|
+
/** The row to patch with the answer. The receiver mints it before calling. */
|
|
94
|
+
msgId?: string;
|
|
95
|
+
prompt?: string;
|
|
96
|
+
callback?: { url?: string; secret?: string };
|
|
97
|
+
/** Receiver capability negotiation. `'segments'` means "I can store and render structured tool
|
|
98
|
+
* segments" — see `WebhookStreamerOptions.sendSegments`. ABSENT means no, and the receiver gets
|
|
99
|
+
* the flattened in-text tool markers instead. A list, so it can grow without a new field. */
|
|
100
|
+
accepts?: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Run one turn, streaming into the receiver's row. Errors settle the row VISIBLY — a reader must
|
|
104
|
+
* never be left watching a "typing…" placeholder that will never resolve. Exported so an add-on
|
|
105
|
+
* with its own ingress (a queue consumer, say) can reuse the run half without the route half. */
|
|
106
|
+
export async function runWebhookTurn(args: {
|
|
107
|
+
callback: WebhookTarget; convId: string; msgId: string; sessionId: string; prompt: string;
|
|
108
|
+
uid: string; userEmail: string; sendSegments: boolean;
|
|
109
|
+
channel: string; source: string;
|
|
110
|
+
streamer?: WebhookLaneOptions['streamer'];
|
|
111
|
+
}): Promise<void> {
|
|
112
|
+
const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments, channel, source } = args;
|
|
113
|
+
const streamer = new WebhookStreamer({ callback, convId, msgId, sendSegments, ...(args.streamer ?? {}) });
|
|
114
|
+
const abortController = new AbortController();
|
|
115
|
+
|
|
116
|
+
// Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
|
|
117
|
+
// 'api') and this is an authenticated API caller. Widening it just to label the medium would
|
|
118
|
+
// touch recovery and status code paths for no behavioural gain.
|
|
119
|
+
if (!acquireSessionLock(sessionId, 'api', abortController)) {
|
|
120
|
+
// sessionId defaults to convId, so this is genuinely "you sent two messages into the same
|
|
121
|
+
// thread while the first was still running". Say so rather than dropping it silently.
|
|
122
|
+
await streamer.fail('That conversation is already processing a message — wait for it to finish.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
upsertSession(sessionId, prompt, { uid, email: userEmail });
|
|
126
|
+
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel });
|
|
127
|
+
setRunStatus(sessionId, 'running', 'web');
|
|
128
|
+
|
|
129
|
+
const blocks: ConvBlock[] = [];
|
|
130
|
+
let text = '';
|
|
131
|
+
try {
|
|
132
|
+
for await (const ev of streamChat({
|
|
133
|
+
prompt, sessionId, uid, userEmail,
|
|
134
|
+
mcpServers: getMcpConfig(uid),
|
|
135
|
+
abortController,
|
|
136
|
+
context: { source, user: userEmail },
|
|
137
|
+
onPermissionRequest: async () => ({ allow: true }),
|
|
138
|
+
})) {
|
|
139
|
+
if (ev.type === 'text_delta') { text += ev.text; streamer.feed({ type: 'text_delta', text: ev.text }); }
|
|
140
|
+
else if (ev.type === 'tool_use') {
|
|
141
|
+
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
142
|
+
blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
143
|
+
streamer.feed({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
144
|
+
}
|
|
145
|
+
else if (ev.type === 'tool_result') {
|
|
146
|
+
blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
147
|
+
// The `running` → `completed`/`error` transition. Fed unconditionally; the streamer ignores
|
|
148
|
+
// it unless the receiver negotiated segments.
|
|
149
|
+
streamer.feed({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output, isError: ev.isError });
|
|
150
|
+
}
|
|
151
|
+
else if (ev.type === 'done') break;
|
|
152
|
+
else if (ev.type === 'error') {
|
|
153
|
+
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
154
|
+
blocks.push({ type: 'error', text: ev.message });
|
|
155
|
+
await streamer.fail(ev.message);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (text) blocks.push({ type: 'text', text });
|
|
160
|
+
await streamer.finish();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`[${channel}] turn failed:`, (err as Error).message);
|
|
163
|
+
await streamer.fail((err as Error).message || 'agent error');
|
|
164
|
+
} finally {
|
|
165
|
+
// The transcript is persisted whatever happened, so the shraga UI and the next turn's context
|
|
166
|
+
// see the same history the receiver saw.
|
|
167
|
+
if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
|
|
168
|
+
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build a `ServerFeature` that mounts one turn ingress. Register it with `registerFeature()`.
|
|
174
|
+
*
|
|
175
|
+
* No capability flag is declared: `flags` tells the CLIENT that a surface exists, and this lane is
|
|
176
|
+
* driven entirely by the external product calling in — nothing in the client gates on it. An add-on
|
|
177
|
+
* that DOES have a client surface can declare its own flag by spreading this feature.
|
|
178
|
+
*/
|
|
179
|
+
export function createWebhookLaneFeature(opts: WebhookLaneOptions): ServerFeature {
|
|
180
|
+
const channel = opts.channel ?? opts.name;
|
|
181
|
+
const source = opts.source ?? opts.name;
|
|
182
|
+
// Per-INSTANCE, not module-global: two lanes may be registered, and a shared guard would let the
|
|
183
|
+
// first one mounted silence the second.
|
|
184
|
+
let mounted = false;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
name: opts.name,
|
|
188
|
+
|
|
189
|
+
register(ctx: FeatureContext): void {
|
|
190
|
+
if (ctx.passive || mounted) return;
|
|
191
|
+
mounted = true;
|
|
192
|
+
|
|
193
|
+
ctx.app.post(opts.route, (req, res) => {
|
|
194
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
|
|
195
|
+
const caller = bearer ? validateApiKey(bearer) : null;
|
|
196
|
+
if (!caller) return void res.status(401).json({ error: 'unauthorized' });
|
|
197
|
+
|
|
198
|
+
const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as TurnRequestBody;
|
|
199
|
+
if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
|
|
200
|
+
if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
|
|
201
|
+
try {
|
|
202
|
+
const u = new URL(callback.url);
|
|
203
|
+
if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
|
|
204
|
+
} catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
|
|
205
|
+
|
|
206
|
+
const cb: WebhookTarget = { url: callback.url, secret: callback.secret, connId };
|
|
207
|
+
try {
|
|
208
|
+
opts.onTurnAccepted?.({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.warn(`[${opts.name}] onTurnAccepted threw:`, (err as Error).message);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ACCEPT, then run. The answer arrives on the callback, so holding this response open would
|
|
214
|
+
// only give the caller's trigger a socket to time out on.
|
|
215
|
+
res.json({ status: 'accepted', sessionId: sessionId || convId });
|
|
216
|
+
void runWebhookTurn({
|
|
217
|
+
callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
|
|
218
|
+
uid: caller.uid, userEmail: caller.email,
|
|
219
|
+
sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
|
|
220
|
+
channel, source, streamer: opts.streamer,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(`[${opts.name}] turn ingress mounted at POST ${opts.route}`);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* WebhookStreamer — progressive delivery of ONE agent turn to an external receiver over signed
|
|
3
|
+
* webhook callbacks. Name-neutral half of the webhook lane; see `feature.ts` for the seam that
|
|
4
|
+
* mounts it.
|
|
3
5
|
*
|
|
4
|
-
* WHY NOT
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* call against Slack's three-call streaming protocol, and it lives in a vendored package in a
|
|
10
|
-
* different repo. Para's transport is one signed POST per flush carrying the accumulated text —
|
|
11
|
-
* there is no start/append/stop handshake and no ts to thread. Forking that package to
|
|
12
|
-
* parameterize the transport would be a larger, riskier change to Slack's live path than these
|
|
13
|
-
* ~60 lines, so the shared thing is the CONTRACT, not the code, and this comment is the seam.
|
|
6
|
+
* WHY A CALLBACK AND NOT A HELD RESPONSE. The receiver POSTs one turn in and we answer on a second,
|
|
7
|
+
* independent request rather than streaming down the ingress response. An agent run is minutes
|
|
8
|
+
* long, so a held body dies to a proxy idle timeout with a half-written row; and a proactive post
|
|
9
|
+
* (a deploy notice, a scheduled digest) gets the same transport for free, with no inbound turn to
|
|
10
|
+
* ride.
|
|
14
11
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* WHY NOT `SlackStreamer`. Its throttling CONTRACT is reused deliberately — buffer, `flushInterval`
|
|
13
|
+
* (300ms), `flushThreshold` (30 chars), and a serialized `flushChain` so sends never overtake each
|
|
14
|
+
* other, all mirrored here with the same defaults. Its *transport* cannot be: every send in that
|
|
15
|
+
* class is a Slack `chat.appendStream`/`chat.startStream`/`chat.update` call against Slack's
|
|
16
|
+
* three-call streaming protocol. This transport is one signed POST per flush carrying the
|
|
17
|
+
* accumulated text — no start/append/stop handshake, no ts to thread. So the shared thing is the
|
|
18
|
+
* CONTRACT, not the code, and this comment is the seam.
|
|
19
|
+
*
|
|
20
|
+
* ACCUMULATE, DON'T APPEND: each flush sends the full text so far, and the receiver is expected to
|
|
21
|
+
* patch the message row with a whole-row `set`. A dropped or reordered delta then self-heals on the
|
|
22
|
+
* next flush instead of leaving a hole — worth more than the bytes.
|
|
18
23
|
*/
|
|
19
24
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
20
25
|
|
|
21
|
-
export interface
|
|
22
|
-
/** Absolute webhook URL, handed to us per-turn by
|
|
26
|
+
export interface WebhookTarget {
|
|
27
|
+
/** Absolute webhook URL, handed to us per-turn by the receiver (never configured here). */
|
|
23
28
|
url: string;
|
|
24
29
|
/** HMAC key for THIS connection, handed over per-turn so a rotation lands on the next message. */
|
|
25
30
|
secret: string;
|
|
@@ -27,15 +32,15 @@ export interface ParaCallback {
|
|
|
27
32
|
connId: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
export interface
|
|
31
|
-
callback:
|
|
35
|
+
export interface WebhookStreamerOptions {
|
|
36
|
+
callback: WebhookTarget;
|
|
32
37
|
convId: string;
|
|
33
38
|
/** The row to patch. Omit for a PROACTIVE post (no preceding user turn) — see `post()`. */
|
|
34
39
|
msgId?: string;
|
|
35
40
|
flushInterval?: number;
|
|
36
41
|
flushThreshold?: number;
|
|
37
42
|
/** Show a transient inline marker per tool call, as the Slack streamer does. Default on.
|
|
38
|
-
* Ignored when `sendSegments` is on — see `
|
|
43
|
+
* Ignored when `sendSegments` is on — see `WebhookStreamerOptions.sendSegments`. */
|
|
39
44
|
toolMarkers?: boolean;
|
|
40
45
|
/**
|
|
41
46
|
* Emit STRUCTURED segments (`text` + `tool`) alongside the accumulated text.
|
|
@@ -54,11 +59,12 @@ export interface ParaStreamerOptions {
|
|
|
54
59
|
postTimeout?: number;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
/** Signature
|
|
58
|
-
*
|
|
59
|
-
*
|
|
62
|
+
/** Signature over one delivery. THIS IS A CROSS-PROCESS CONTRACT: the receiver reimplements it in
|
|
63
|
+
* its own codebase, so changing a single byte of the material silently 401s every delta. Anything
|
|
64
|
+
* built on this seam should freeze a known-answer vector on BOTH sides (see the frozen digest in
|
|
65
|
+
* `__tests__/webhook-streamer.test.ts`).
|
|
60
66
|
*
|
|
61
|
-
* The DELIVERY id is in the material because
|
|
67
|
+
* The DELIVERY id is in the material because a receiver's replay guard dedupes on that header alone;
|
|
62
68
|
* unsigned, it would be the one field an attacker could vary freely to replay a captured delivery
|
|
63
69
|
* inside the signature window.
|
|
64
70
|
*
|
|
@@ -70,17 +76,15 @@ export interface ParaStreamerOptions {
|
|
|
70
76
|
* leaves `deliveryId` as the only free field, and it sits between two fixed-shape neighbours, so
|
|
71
77
|
* no (deliveryId, ts) pair can be re-cut into a different one. If either check is ever relaxed,
|
|
72
78
|
* length-prefix the material instead of relying on this. */
|
|
73
|
-
export function
|
|
79
|
+
export function signDelivery(secret: string, connId: string, deliveryId: string, ts: number, rawBody: string): string {
|
|
74
80
|
return 'v1=' + createHmac('sha256', secret).update(`${connId}.${deliveryId}.${ts}.${rawBody}`).digest('hex');
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
// ── Structured segments ──────────────────────────────────────────────────────
|
|
78
84
|
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// second row field, and no second thing to keep in sync. Duplicated here rather than imported for
|
|
83
|
-
// the same reason `signPara` is — the two repos publish separately.
|
|
85
|
+
// The structured mirror of a turn: what the receiver renders as collapsible tool pills alongside
|
|
86
|
+
// the text. It is a WIRE shape, not an internal one — a receiver reimplements it, so treat a field
|
|
87
|
+
// rename here as a breaking change and version it on both sides.
|
|
84
88
|
|
|
85
89
|
export interface ToolCallInfo {
|
|
86
90
|
id: string;
|
|
@@ -100,13 +104,13 @@ export type MessageSegment =
|
|
|
100
104
|
* APPEND at the top). Unclamped, one such call would be re-uploaded on every subsequent delta for
|
|
101
105
|
* the rest of the turn and then parked in a database row forever.
|
|
102
106
|
*
|
|
103
|
-
* The caps are chosen against what
|
|
104
|
-
*
|
|
105
|
-
*
|
|
107
|
+
* The caps are chosen against what a tool pill actually shows: args as one JSON line and a result
|
|
108
|
+
* sliced at a few hundred chars, so anything past a few KB is invisible detail that still costs
|
|
109
|
+
* bandwidth and storage. Truncation is MARKED with the true length — a silently
|
|
106
110
|
* shortened `Bash` output is a lie the reader cannot detect.
|
|
107
111
|
*
|
|
108
|
-
* These are the SENDER's caps.
|
|
109
|
-
*
|
|
112
|
+
* These are the SENDER's caps. A receiver must re-clamp on ingest too, because a cap that only
|
|
113
|
+
* exists on the sender is not a cap.
|
|
110
114
|
*/
|
|
111
115
|
export const MAX_TOOL_ARGS_CHARS = 2_000;
|
|
112
116
|
export const MAX_TOOL_RESULT_CHARS = 4_000;
|
|
@@ -140,8 +144,7 @@ export function clampArgs(input: unknown): Record<string, unknown> | undefined {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
/**
|
|
143
|
-
* Wall clock on ONE delivery.
|
|
144
|
-
* which is the other half of this lane.
|
|
147
|
+
* Wall clock on ONE delivery.
|
|
145
148
|
*
|
|
146
149
|
* WHY A TIMEOUT IS LOAD-BEARING HERE AND NOT A NICETY: flushes are serialized through `flushChain`,
|
|
147
150
|
* and `finish()` awaits that chain. `fetch` has no default timeout, so ONE POST that connects and
|
|
@@ -155,15 +158,15 @@ export const POST_TIMEOUT_MS = 20_000;
|
|
|
155
158
|
|
|
156
159
|
/** One signed POST. Returns false on any non-2xx, timeout, or network error, having logged it — the
|
|
157
160
|
* caller keeps streaming rather than aborting the agent's turn over a transport hiccup. */
|
|
158
|
-
export async function
|
|
161
|
+
export async function postDelivery(cb: WebhookTarget, payload: object, timeoutMs: number = POST_TIMEOUT_MS): Promise<boolean> {
|
|
159
162
|
const raw = JSON.stringify(payload);
|
|
160
163
|
const ts = Date.now();
|
|
161
|
-
// Per-DELIVERY id, not per-turn:
|
|
164
|
+
// Per-DELIVERY id, not per-turn: a receiver's replay guard dedupes on this, so a shared id across
|
|
162
165
|
// the deltas of one turn would drop every delta after the first. Minted here so the exact same
|
|
163
166
|
// value goes into the header AND the signature — they must not be able to diverge.
|
|
164
167
|
const delivery = randomUUID();
|
|
165
|
-
// Abort on a timer rather than `AbortSignal.timeout
|
|
166
|
-
//
|
|
168
|
+
// Abort on a timer rather than `AbortSignal.timeout` so the timer can be cleared in `finally` —
|
|
169
|
+
// a fast POST then leaves nothing pending on the event loop.
|
|
167
170
|
const ac = new AbortController();
|
|
168
171
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
169
172
|
try {
|
|
@@ -174,29 +177,34 @@ export async function postPara(cb: ParaCallback, payload: object, timeoutMs: num
|
|
|
174
177
|
'Content-Type': 'application/json',
|
|
175
178
|
'x-agent-conn': cb.connId,
|
|
176
179
|
'x-agent-timestamp': String(ts),
|
|
177
|
-
'x-agent-signature':
|
|
180
|
+
'x-agent-signature': signDelivery(cb.secret, cb.connId, delivery, ts, raw),
|
|
178
181
|
'x-agent-delivery': delivery,
|
|
179
182
|
},
|
|
180
183
|
body: raw,
|
|
181
184
|
});
|
|
182
185
|
if (!res.ok) {
|
|
183
|
-
console.warn(`[
|
|
186
|
+
console.warn(`[webhook-lane] ${(payload as any).type} rejected: ${res.status} ${await res.text().catch(() => '')}`.slice(0, 300));
|
|
184
187
|
return false;
|
|
185
188
|
}
|
|
186
189
|
return true;
|
|
187
190
|
} catch (err) {
|
|
188
191
|
const e = err as Error;
|
|
189
|
-
console.warn('[
|
|
192
|
+
console.warn('[webhook-lane] delivery failed:', e.name === 'AbortError' ? `no response within ${timeoutMs}ms` : e.message);
|
|
190
193
|
return false;
|
|
191
194
|
} finally {
|
|
192
195
|
clearTimeout(timer);
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
198
|
|
|
196
|
-
export class
|
|
199
|
+
export class WebhookStreamer {
|
|
197
200
|
private buffer = '';
|
|
198
201
|
private fullText = '';
|
|
199
202
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
203
|
+
/** When a TOOL event last forced a delta out, so a burst of them is rate-limited by the same
|
|
204
|
+
* `flushInterval` text uses. Deliberately NOT bumped by text flushes: a tool call arriving right
|
|
205
|
+
* after the model spoke is exactly the case that must show its pill at once. `0` = never, so the
|
|
206
|
+
* first tool of a turn is always immediate. */
|
|
207
|
+
private lastToolFlushAt = 0;
|
|
200
208
|
private flushChain: Promise<void> = Promise.resolve();
|
|
201
209
|
private aborted = false;
|
|
202
210
|
private afterTool = false;
|
|
@@ -213,7 +221,7 @@ export class ParaStreamer {
|
|
|
213
221
|
private readonly sendSegments: boolean;
|
|
214
222
|
private readonly postTimeout: number;
|
|
215
223
|
|
|
216
|
-
constructor(private readonly opts:
|
|
224
|
+
constructor(private readonly opts: WebhookStreamerOptions) {
|
|
217
225
|
this.flushInterval = opts.flushInterval ?? 300;
|
|
218
226
|
this.flushThreshold = opts.flushThreshold ?? 30;
|
|
219
227
|
this.sendSegments = opts.sendSegments ?? false;
|
|
@@ -245,10 +253,10 @@ export class ParaStreamer {
|
|
|
245
253
|
this.segments.push({ type: 'tool', tool: info });
|
|
246
254
|
}
|
|
247
255
|
this.toolCount++;
|
|
248
|
-
this.
|
|
256
|
+
this.flushToolChange();
|
|
249
257
|
} else if (this.toolMarkers) {
|
|
250
258
|
// In-band, transient: `finish()` sends the clean final text, which replaces the row wholesale
|
|
251
|
-
// (
|
|
259
|
+
// (the receiver writes the whole row), so the marker disappears on its own.
|
|
252
260
|
this.afterTool = true;
|
|
253
261
|
this.fullText += `\n\n_🔧 ${ev.tool.slice(0, 200)}_\n\n`;
|
|
254
262
|
this.enqueueFlush();
|
|
@@ -260,13 +268,12 @@ export class ParaStreamer {
|
|
|
260
268
|
if (!info) return;
|
|
261
269
|
info.status = ev.isError ? 'error' : 'completed';
|
|
262
270
|
if (ev.output) info.result = clampText(ev.output, MAX_TOOL_RESULT_CHARS);
|
|
263
|
-
this.
|
|
271
|
+
this.flushToolChange();
|
|
264
272
|
}
|
|
265
273
|
}
|
|
266
274
|
|
|
267
|
-
/** Grow the trailing text segment, or start one
|
|
268
|
-
*
|
|
269
|
-
* instead of shredding the answer into a segment per delta. */
|
|
275
|
+
/** Grow the trailing text segment, or start one: consecutive text stays ONE segment, so the pills
|
|
276
|
+
* sit between paragraphs instead of shredding the answer into a segment per delta. */
|
|
270
277
|
private appendSegmentText(text: string): void {
|
|
271
278
|
if (!this.sendSegments) return;
|
|
272
279
|
const last = this.segments[this.segments.length - 1];
|
|
@@ -291,7 +298,7 @@ export class ParaStreamer {
|
|
|
291
298
|
await this.flushChain;
|
|
292
299
|
if (this.aborted || !this.opts.msgId) return this.fullText;
|
|
293
300
|
const text = this.fullText.trim() || '(no output)';
|
|
294
|
-
await
|
|
301
|
+
await postDelivery(this.opts.callback, {
|
|
295
302
|
type: 'final', convId: this.opts.convId, msgId: this.opts.msgId, text,
|
|
296
303
|
...(this.sendSegments ? { segments: this.segmentSnapshot() } : {}),
|
|
297
304
|
}, this.postTimeout);
|
|
@@ -304,15 +311,38 @@ export class ParaStreamer {
|
|
|
304
311
|
this.clearTimer();
|
|
305
312
|
await this.flushChain;
|
|
306
313
|
if (!this.opts.msgId) return;
|
|
307
|
-
await
|
|
314
|
+
await postDelivery(this.opts.callback, { type: 'error', convId: this.opts.convId, msgId: this.opts.msgId, message }, this.postTimeout);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Tool events, throttled — but never at the cost of "a tool that is still running reads as
|
|
319
|
+
* running", which is the whole point of the pill.
|
|
320
|
+
*
|
|
321
|
+
* Each flush re-sends the WHOLE accumulated snapshot (up to ~600 KB at the ingest caps) and
|
|
322
|
+
* costs a whole-row `set` plus a live re-render on the receiver, so a POST per `tool_use` AND
|
|
323
|
+
* per `tool_result` made a 100-tool turn ~200 POSTs and tens of MB — quadratic in the tool
|
|
324
|
+
* count. Text has always been throttled for exactly this reason; tool events simply were not.
|
|
325
|
+
*
|
|
326
|
+
* So they take the same `flushInterval` budget: go out NOW if nothing has been sent within it
|
|
327
|
+
* (a turn that opens with a tool still shows its pill immediately), otherwise arm the timer. The
|
|
328
|
+
* timer is the guarantee — every state change is delivered within `flushInterval`, so no pill is
|
|
329
|
+
* ever stranded and the receiver's stall watchdog keeps getting fed.
|
|
330
|
+
*/
|
|
331
|
+
private flushToolChange(): void {
|
|
332
|
+
if (Date.now() - this.lastToolFlushAt >= this.flushInterval) {
|
|
333
|
+
this.lastToolFlushAt = Date.now();
|
|
334
|
+
this.enqueueFlush();
|
|
335
|
+
} else {
|
|
336
|
+
this.scheduleTimer();
|
|
337
|
+
}
|
|
308
338
|
}
|
|
309
339
|
|
|
310
340
|
private enqueueFlush(): void {
|
|
311
341
|
this.clearTimer();
|
|
312
342
|
// `segments` matters here too: a turn that opens with a tool call has produced NO text yet, and
|
|
313
343
|
// the old guard would have swallowed that flush — so the first pill would not appear until the
|
|
314
|
-
// model started talking, and, worse, the delta that
|
|
315
|
-
//
|
|
344
|
+
// model started talking, and, worse, the delta that feeds a receiver's stall watchdog would
|
|
345
|
+
// never be sent for a long tool-only stretch.
|
|
316
346
|
if (!this.fullText && !this.segments.length) return;
|
|
317
347
|
this.buffer = '';
|
|
318
348
|
const snapshot = this.fullText;
|
|
@@ -320,8 +350,8 @@ export class ParaStreamer {
|
|
|
320
350
|
// Serialized: a later, longer snapshot must never be overtaken by an earlier one, or the row
|
|
321
351
|
// visibly rewinds mid-stream.
|
|
322
352
|
this.flushChain = this.flushChain
|
|
323
|
-
.then(async () => { await
|
|
324
|
-
.catch((err) => console.warn('[
|
|
353
|
+
.then(async () => { await postDelivery(this.opts.callback, { type: 'delta', convId: this.opts.convId, msgId: this.opts.msgId, text: snapshot, ...(segs ? { segments: segs } : {}) }, this.postTimeout); })
|
|
354
|
+
.catch((err) => console.warn('[webhook-lane] flush error:', (err as Error).message));
|
|
325
355
|
}
|
|
326
356
|
|
|
327
357
|
private scheduleTimer(): void {
|
|
@@ -335,7 +365,7 @@ export class ParaStreamer {
|
|
|
335
365
|
}
|
|
336
366
|
|
|
337
367
|
/** PROACTIVE post — a scheduled run, a deploy notice, a downtime report. No preceding user turn,
|
|
338
|
-
* so there is no row to patch:
|
|
339
|
-
export function
|
|
340
|
-
return
|
|
368
|
+
* so there is no row to patch: the receiver mints one. Same signed transport, same fence. */
|
|
369
|
+
export function postNotice(cb: WebhookTarget, convId: string, text: string): Promise<boolean> {
|
|
370
|
+
return postDelivery(cb, { type: 'post', convId, text });
|
|
341
371
|
}
|