shraga 0.1.86 → 0.1.88
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/package.json +1 -1
- package/src/cli.ts +28 -51
- package/src/server/boot.ts +13 -7
- package/src/server/data-sync.ts +36 -3
- package/src/server/notify-owners.ts +7 -6
- 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
package/README.md
CHANGED
|
@@ -85,7 +85,9 @@ The core is deliberately small and grows through documented seams (see
|
|
|
85
85
|
| **Server features** (`src/server/features.ts`) | Mount new server-side surfaces via `registerFeature`. |
|
|
86
86
|
| **Client slots** (`src/client/lib/slots.tsx`) | Inject UI into typed render slots without touching core. |
|
|
87
87
|
| **Route extensions** (`data/extensions/*.ext.ts`) | Drop-in public routes (webhooks, OAuth callbacks) per deployment. |
|
|
88
|
+
| **Webhook lane** (`src/server/webhook-lane/`) | Give an external chat product a turn ingress that streams the answer back to a signed callback. |
|
|
88
89
|
| **`SHRAGA_OVERLAY`** | Load a whole external add-on module at startup (the optional add-ons above). |
|
|
90
|
+
| **`SHRAGA_CLI_EXT`** | Hand any subcommand the core does not own to an external CLI module. |
|
|
89
91
|
|
|
90
92
|
## Use as a library
|
|
91
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/cli.ts
CHANGED
|
@@ -29,19 +29,23 @@ Subcommands:
|
|
|
29
29
|
ingress Run the host-header TCP router (INGRESS_PORT, default 3100)
|
|
30
30
|
for previews + blue-green flips. Own process, survives restarts.
|
|
31
31
|
user add <email> <pw> Seed a local username/password user
|
|
32
|
-
|
|
33
|
-
Text from [text], --file <path>, or stdin (preferred for
|
|
34
|
-
multi-line reports). --conn <id> picks a link when several exist.
|
|
32
|
+
<add-on> Any other subcommand is handed to SHRAGA_CLI_EXT, if set.
|
|
35
33
|
|
|
36
34
|
Environment:
|
|
37
35
|
CLOUDFLARE_TUNNEL_TOKEN If set, starts a Cloudflare Tunnel alongside the server.
|
|
38
36
|
Get the token from Cloudflare Zero Trust > Tunnels > Configure.
|
|
39
37
|
ANTHROPIC_API_KEY Claude API key (or use \`claude auth login\` for subscription auth)
|
|
40
38
|
VITE_FIREBASE_CONFIG_PROD Firebase config JSON for auth (prod project)
|
|
39
|
+
SHRAGA_CLI_EXT Absolute path to an add-on CLI module. Any subcommand this CLI
|
|
40
|
+
does not own is handed to it before the server boots. See the
|
|
41
|
+
CLI EXTENSION SEAM comment in src/cli.ts.
|
|
41
42
|
`.trim());
|
|
42
43
|
process.exit(0);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** Subcommands the core owns. Everything else is offered to SHRAGA_CLI_EXT (see below). */
|
|
47
|
+
const CORE_SUBCOMMANDS = new Set(['user', 'ingress']);
|
|
48
|
+
|
|
45
49
|
const port = flag('port', 'p');
|
|
46
50
|
const dataDir = flag('data-dir', 'd');
|
|
47
51
|
|
|
@@ -62,58 +66,31 @@ if (args[0] === 'user' && args[1] === 'add') {
|
|
|
62
66
|
process.exit(0);
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
//
|
|
69
|
+
// ── CLI EXTENSION SEAM ───────────────────────────────────────────────────────
|
|
66
70
|
//
|
|
67
|
-
// The
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// all: it composes text on its own clock with no inbound turn to answer and no deploy event to ride.
|
|
71
|
-
// This is that door, and it is a CLI rather than a route because the caller is the agent itself,
|
|
72
|
-
// running Bash on this very box: a local process reading the same `para-links.json` the feature
|
|
73
|
-
// writes needs no listener, no API key, and no second copy of the callback secret.
|
|
71
|
+
// The twin of the `registerFeature` / SHRAGA_OVERLAY seam, for the command line. The core owns a
|
|
72
|
+
// short, fixed list of subcommands; ANY other one is handed to the module named by SHRAGA_CLI_EXT,
|
|
73
|
+
// so a downstream distribution can ship its own commands without the core naming them.
|
|
74
74
|
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
const link = links[connId];
|
|
94
|
-
|
|
95
|
-
const file = flag('file');
|
|
96
|
-
let text: string;
|
|
97
|
-
if (file) {
|
|
98
|
-
text = (await import('node:fs')).readFileSync(file, 'utf-8');
|
|
99
|
-
} else {
|
|
100
|
-
// A bare positional (not a flag, and not a flag's VALUE) is accepted for one-liners.
|
|
101
|
-
const flagVals = new Set<string>();
|
|
102
|
-
for (let i = 0; i < args.length; i++) if (args[i].startsWith('--')) flagVals.add(args[i + 1]);
|
|
103
|
-
const positional = args.slice(2).find((a) => !a.startsWith('--') && !flagVals.has(a));
|
|
104
|
-
text = positional ?? await new Response(Bun.stdin.stream()).text();
|
|
105
|
-
}
|
|
106
|
-
if (!text.trim()) {
|
|
107
|
-
console.error('nothing to post: text was empty (pipe it on stdin, pass --file, or give it as an argument)');
|
|
75
|
+
// CONTRACT. The module is imported for side effects, with `process.argv` untouched — it reads argv
|
|
76
|
+
// itself, and calls `process.exit()` when it has handled the command. If it returns without
|
|
77
|
+
// exiting, we fall through to the normal server boot, which is exactly what an unrecognised
|
|
78
|
+
// subcommand does today. It runs AFTER env resolution (so DATA_DIR/PORT are settled and the
|
|
79
|
+
// extension sees the same data dir the server would) and BEFORE any server boot.
|
|
80
|
+
//
|
|
81
|
+
// WHY AN ENV VAR AND NOT A REGISTRY. There is no server process to register against — this is a
|
|
82
|
+
// one-shot CLI. The path must come from the deployment, and the deployment already sets DATA_DIR
|
|
83
|
+
// and friends the same way.
|
|
84
|
+
const cliExt = process.env.SHRAGA_CLI_EXT?.trim();
|
|
85
|
+
if (cliExt && args[0] && !args[0].startsWith('-') && !CORE_SUBCOMMANDS.has(args[0])) {
|
|
86
|
+
const { pathToFileURL } = await import('node:url');
|
|
87
|
+
const { resolve } = await import('node:path');
|
|
88
|
+
try {
|
|
89
|
+
await import(pathToFileURL(resolve(cliExt)).href);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[cli-ext] failed to load ${cliExt}:`, (err as Error)?.stack || err);
|
|
108
92
|
process.exit(1);
|
|
109
93
|
}
|
|
110
|
-
|
|
111
|
-
const ok = await postProactive({ url: link.url, secret: link.secret, connId }, link.convId, text);
|
|
112
|
-
// Exit code is the point: `postProactive` swallows transport failures into `false`, so a caller
|
|
113
|
-
// that only looked at stdout would read a silent drop as a successful delivery.
|
|
114
|
-
if (!ok) { console.error(`\u2716 para post FAILED \u2192 ${link.convId}`); process.exit(1); }
|
|
115
|
-
console.log(`\u2705 posted to ${link.convId}`);
|
|
116
|
-
process.exit(0);
|
|
117
94
|
}
|
|
118
95
|
|
|
119
96
|
// `shraga ingress` — host-header TCP router for previews + blue-green flips.
|
package/src/server/boot.ts
CHANGED
|
@@ -25,7 +25,6 @@ import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAu
|
|
|
25
25
|
import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
|
|
26
26
|
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
27
27
|
import { slackFeature } from './slack/feature.ts';
|
|
28
|
-
import { paraFeature } from './para/feature.ts';
|
|
29
28
|
import { dataPath } from './paths.ts';
|
|
30
29
|
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
31
30
|
import { setBroadcaster } from './session-bus.ts';
|
|
@@ -113,7 +112,15 @@ const PASSIVE_FLAG = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
|
|
|
113
112
|
const PASSIVE = PASSIVE_FLAG === '1' || PASSIVE_FLAG === 'true';
|
|
114
113
|
if (PASSIVE) console.log('[server] PASSIVE mode — schedulers, consumers and background writers disabled');
|
|
115
114
|
|
|
115
|
+
// Local-only prep (git config, gitignore, untracking). The NETWORK half — fetch/merge — is
|
|
116
|
+
// deliberately NOT awaited here; it runs via dataSync.syncOnBoot() AFTER listen(). See syncOnBoot().
|
|
116
117
|
if (!PASSIVE) await dataSync.init();
|
|
118
|
+
|
|
119
|
+
// Kick the boot-time data sync detached, AFTER the port is bound. syncOnBoot() never rejects, and the
|
|
120
|
+
// extra .catch() is belt-and-braces: an unhandled rejection here would hit the process-level handler.
|
|
121
|
+
function bootDataSync(): void {
|
|
122
|
+
dataSync.syncOnBoot().catch(err => console.error('[data-sync] boot sync error:', (err as Error).message));
|
|
123
|
+
}
|
|
117
124
|
await loadShragaConfig();
|
|
118
125
|
// Programmatic engines register through the same seam an overlay uses — BEFORE initEngines() so
|
|
119
126
|
// getAvailableEngines() includes them and a directive can resolve to one immediately.
|
|
@@ -993,8 +1000,8 @@ function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket:
|
|
|
993
1000
|
// proxy-local reply only proves THIS hop is alive: if the proxy→sidecar leg is half-open, or the
|
|
994
1001
|
// sidecar has already dropped this client from its subscriber set, the browser still gets pongs,
|
|
995
1002
|
// keeps `readyState === OPEN`, shows a green "connected" dot, and every keystroke disappears.
|
|
996
|
-
// The probe is only worth anything end-to-end, so the sidecar owns the reply (
|
|
997
|
-
//
|
|
1003
|
+
// The probe is only worth anything end-to-end, so the sidecar owns the reply (it answers in its
|
|
1004
|
+
// own ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
|
|
998
1005
|
// outcome — the client then reconnects rather than trusting a dead pipe.
|
|
999
1006
|
if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
|
|
1000
1007
|
else if (!opened && pending.length < 256) pending.push({ data, isBinary }); // bounded: never buffer unboundedly
|
|
@@ -1058,7 +1065,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1058
1065
|
} else {
|
|
1059
1066
|
const port = req.url ? resolveSidecarPort(req.url) : null;
|
|
1060
1067
|
if (port) {
|
|
1061
|
-
// A sidecar socket is a LIVE ATTACHED SHELL (
|
|
1068
|
+
// A sidecar socket is a LIVE ATTACHED SHELL (a terminal daemon) — read/write on the user's terminals. It
|
|
1062
1069
|
// must be authenticated BEFORE we bridge it, like the HTTP routes (`requireAuth`) and the `/ws`
|
|
1063
1070
|
// control socket (its `auth` message). NOTE the limit of this gate: it is IDENTITY-ONLY. It proves
|
|
1064
1071
|
// the token belongs to a valid user; it does NOT check that the user owns (or may access) the
|
|
@@ -1215,9 +1222,6 @@ if (process.env.SHRAGA_OVERLAY) {
|
|
|
1215
1222
|
// so their routes mount ahead of the SPA fallback, identical to the overlay path.
|
|
1216
1223
|
for (const f of __reg.features ?? []) registerFeature(f);
|
|
1217
1224
|
registerFeature(slackFeature);
|
|
1218
|
-
// para-li external-agent lane — a second medium alongside Slack; both subscribe the owner-notice
|
|
1219
|
-
// bus independently, so neither affects the other.
|
|
1220
|
-
registerFeature(paraFeature);
|
|
1221
1225
|
mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
|
|
1222
1226
|
// Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
|
|
1223
1227
|
Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
|
|
@@ -1233,6 +1237,7 @@ async function activateConsumers() {
|
|
|
1233
1237
|
activated = true;
|
|
1234
1238
|
console.log('[server] ACTIVATING — starting consumers and background writers');
|
|
1235
1239
|
await dataSync.init();
|
|
1240
|
+
bootDataSync();
|
|
1236
1241
|
syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
|
|
1237
1242
|
recordBootGap();
|
|
1238
1243
|
startHeartbeat();
|
|
@@ -2150,6 +2155,7 @@ await new Promise<void>((resolve) => {
|
|
|
2150
2155
|
catch (err) { console.warn('[self-upgrade] could not deliver report:', (err as Error).message); }
|
|
2151
2156
|
startSidecars().catch(err => console.error('[sidecar] startup error:', err));
|
|
2152
2157
|
recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
|
|
2158
|
+
bootDataSync();
|
|
2153
2159
|
// The disk MCP catalog is warmed off the turn path by whichever engine consumes it — the CE default
|
|
2154
2160
|
// (Claude Code) hands MCP servers straight to its SDK and needs no catalog. An add-on engine that
|
|
2155
2161
|
// uses the shared catalog registers its own boot/interval warm-up through the overlay.
|
package/src/server/data-sync.ts
CHANGED
|
@@ -66,6 +66,15 @@ export function extractCommitSubject(raw: string): string {
|
|
|
66
66
|
return '';
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Paths that are REWRITTEN or discarded by design: per-run worker logs, per-run task files, and
|
|
70
|
+
* `.bak-*` copies. The shrink guard exists to catch a shared file being gutted (contacts.json
|
|
71
|
+
* 111 -> 5 lines); a task file losing 58 lines because the next run rewrote it is not that. And the
|
|
72
|
+
* guard aborts the WHOLE commit, so one churn file stalls every other file's sync indefinitely —
|
|
73
|
+
* measured 2026-09-07: repeated BLOCKED alerts and a 27-commit push backlog behind two task files. */
|
|
74
|
+
export function isChurnPath(file: string): boolean {
|
|
75
|
+
return /(^|\/)workspace\/[^/]+\/workers\/(logs|tasks)\//.test(file) || /\.bak(-|\.|$)/.test(file);
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
export class DataSyncOptions {
|
|
70
79
|
repoUrl = process.env.DATA_SYNC_REPO || '';
|
|
71
80
|
branch = process.env.DATA_SYNC_BRANCH || 'main';
|
|
@@ -170,8 +179,31 @@ export class DataSync {
|
|
|
170
179
|
await this.untrackIgnored();
|
|
171
180
|
this.ready = true;
|
|
172
181
|
}
|
|
182
|
+
}
|
|
173
183
|
|
|
174
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Boot-time NETWORK sync: the remote fetch/merge plus the deferred conflict-scan + integrity audit.
|
|
186
|
+
* Split out of init() so the HTTP port can bind BEFORE any of this runs.
|
|
187
|
+
*
|
|
188
|
+
* Why: this used to be the tail of init(), which bootServer awaits before listen(). A stalled pull
|
|
189
|
+
* (observed on liv-mac-1: `Could not resolve host: github.com`, plus a 60s LLM commit-message
|
|
190
|
+
* timeout) therefore held the ENTIRE boot — process alive, no listener on any port, 502s for
|
|
191
|
+
* minutes. The port watchdog then SIGTERM/SIGKILLed the boot (LastExitStatus=9) and the next boot
|
|
192
|
+
* re-entered the same stall. Serving possibly-stale data and refreshing a moment later is strictly
|
|
193
|
+
* better than not serving at all.
|
|
194
|
+
*
|
|
195
|
+
* Never rejects: the caller runs this detached, and an unhandled rejection here would reach the
|
|
196
|
+
* process-level handler.
|
|
197
|
+
*/
|
|
198
|
+
async syncOnBoot(): Promise<void> {
|
|
199
|
+
if (!this.ready) return; // init() disabled/aborted — nothing to sync against
|
|
200
|
+
console.log(`${TAG} Boot sync started (background — the server is already serving)`);
|
|
201
|
+
try {
|
|
202
|
+
await this.pull();
|
|
203
|
+
console.log(`${TAG} Boot sync complete`);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(`${TAG} Boot sync FAILED — serving stale data until the next pull:`, (err as Error).message);
|
|
206
|
+
}
|
|
175
207
|
// Defer heavy sync I/O (reads all tracked files + execSync) to avoid blocking
|
|
176
208
|
// WS connections and page loads during startup.
|
|
177
209
|
setTimeout(() => {
|
|
@@ -180,7 +212,8 @@ export class DataSync {
|
|
|
180
212
|
// execSync inside the audit blocks too — keep it off the scan's tick so the two
|
|
181
213
|
// never add up into one long freeze.
|
|
182
214
|
.then(() => new Promise<void>(r => setImmediate(r)))
|
|
183
|
-
.then(() => this.runIntegrityAudit())
|
|
215
|
+
.then(() => this.runIntegrityAudit())
|
|
216
|
+
.catch(err => console.warn(`${TAG} Post-init integrity audit failed:`, (err as Error).message));
|
|
184
217
|
}, 60_000);
|
|
185
218
|
}
|
|
186
219
|
|
|
@@ -236,7 +269,7 @@ export class DataSync {
|
|
|
236
269
|
const [addRaw, delRaw, file] = line.split('\t');
|
|
237
270
|
if (addRaw === '-' || delRaw === '-' || !file) continue; // binary
|
|
238
271
|
const net = (parseInt(delRaw, 10) || 0) - (parseInt(addRaw, 10) || 0);
|
|
239
|
-
if (net > shrinkThreshold) shrunk.push(`• ${file} (−${net} net lines)`);
|
|
272
|
+
if (net > shrinkThreshold && !isChurnPath(file)) shrunk.push(`• ${file} (−${net} net lines)`);
|
|
240
273
|
}
|
|
241
274
|
if (shrunk.length) {
|
|
242
275
|
console.error(`${TAG} 🚫 BLOCKED large content shrink (${context}): ${shrunk.length} file(s) — net-removal threshold is ${shrinkThreshold}`);
|
|
@@ -19,10 +19,10 @@ export function ownerEmails(): string[] {
|
|
|
19
19
|
/** Is this email address an owner of this deployment?
|
|
20
20
|
*
|
|
21
21
|
* Exported because a medium that is not Slack cannot use `resolveOwners`: that returns Slack ids
|
|
22
|
-
* (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer.
|
|
23
|
-
* shraga uid + the email of the API key that opened the link, so it joins on the
|
|
24
|
-
* An empty/unknown address is NOT an owner — the fail-closed direction, since the
|
|
25
|
-
* fanning a deploy report out to whoever happened to
|
|
22
|
+
* (OWNERS ∩ contacts WITH a Slack id), which is a Slack-shaped answer. A webhook-lane add-on, for
|
|
23
|
+
* instance, holds a shraga uid + the email of the API key that opened the link, so it joins on the
|
|
24
|
+
* email instead. An empty/unknown address is NOT an owner — the fail-closed direction, since the
|
|
25
|
+
* alternative is fanning a deploy report out to whoever happened to open a link. */
|
|
26
26
|
export function isOwnerEmail(email: string | undefined | null): boolean {
|
|
27
27
|
const e = String(email ?? '').trim().toLowerCase();
|
|
28
28
|
return !!e && ownerEmails().includes(e);
|
|
@@ -48,8 +48,9 @@ export function senderStamp(): string {
|
|
|
48
48
|
* side.
|
|
49
49
|
*
|
|
50
50
|
* Returns NOTHING, on purpose. It used to return "was there anyone to tell", which was a truthful
|
|
51
|
-
* answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once
|
|
52
|
-
* subscribes the same bus an empty owner list means "no Slack owner", not "nobody was
|
|
51
|
+
* answer only while Slack was the sole medium: `resolveOwners` filters on a SLACK id, so once another
|
|
52
|
+
* medium subscribes the same bus an empty owner list means "no Slack owner", not "nobody was
|
|
53
|
+
* notified".
|
|
53
54
|
* Rather than keep a boolean whose meaning depends on which features happen to be registered — no
|
|
54
55
|
* caller reads it (`data-sync.ts`, `self-upgrade/index.ts`) — the notice is emitted unconditionally
|
|
55
56
|
* and each subscriber decides for itself. The Slack subscriber already no-ops on an empty
|
|
@@ -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
|
}
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* paraFeature — the sender half of the para-li external-agent lane.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors `slackFeature` exactly in shape: one `ServerFeature` that (a) mounts an ingress route and
|
|
5
|
-
* (b) subscribes the owner-notice event bus so deploy / self-upgrade / downtime notices reach the
|
|
6
|
-
* medium. Slack is untouched and the two coexist — both subscribe the same bus, neither knows about
|
|
7
|
-
* the other, and a notice is delivered to each independently.
|
|
8
|
-
*
|
|
9
|
-
* TRANSPORT. para-li POSTs one turn here and we stream the answer BACK to it over signed webhook
|
|
10
|
-
* calls (see `streamer.ts`), rather than holding this response open. para-li's caller is a Bodify
|
|
11
|
-
* trigger whose lifetime is the turn, so a multi-minute agent run held on one response body dies to
|
|
12
|
-
* a proxy idle timeout with a half-written row. Two independent requests also give the proactive
|
|
13
|
-
* lane the same transport for free.
|
|
14
|
-
*
|
|
15
|
-
* TRUST. The turn request is authenticated with an ordinary shraga API key (`POST /api/api-keys`),
|
|
16
|
-
* so reaching this route requires a credential the owner minted. The callback URL + secret arrive
|
|
17
|
-
* IN that authenticated request — para-li tells us where to answer and with what, per turn, which
|
|
18
|
-
* is what makes a rotated webhook secret take effect on the very next message with no config here.
|
|
19
|
-
*/
|
|
20
|
-
import type { ServerFeature, FeatureContext } from '../features.ts';
|
|
21
|
-
import crypto from 'node:crypto';
|
|
22
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
-
import { subscribeEvents } from '../events/bus.ts';
|
|
24
|
-
import { streamChat } from '../claude.ts';
|
|
25
|
-
import { getMcpConfig } from '../mcp.ts';
|
|
26
|
-
import { dataPath } from '../paths.ts';
|
|
27
|
-
import {
|
|
28
|
-
appendMessage, upsertSession, setRunStatus, acquireSessionLock, releaseSessionLock,
|
|
29
|
-
type ConvBlock,
|
|
30
|
-
} from '../sessions.ts';
|
|
31
|
-
import { validateApiKey } from '../api-keys.ts';
|
|
32
|
-
import { isOwnerEmail } from '../notify-owners.ts';
|
|
33
|
-
import { ParaStreamer, postProactive, type ParaCallback } from './streamer.ts';
|
|
34
|
-
|
|
35
|
-
interface DeployNotice { kind: 'deploy'; owners: { name?: string; slackId: string }[]; text: string }
|
|
36
|
-
|
|
37
|
-
/** Last known para conversation per connection — the proactive lane's destination.
|
|
38
|
-
*
|
|
39
|
-
* Learned from the first turn rather than configured: para-li already tells us the conv and the
|
|
40
|
-
* callback on every turn, so a second source of truth would only be a thing to drift. Persisted
|
|
41
|
-
* because a deploy notice fires right after a RESTART, which is exactly when an in-memory map is
|
|
42
|
-
* empty — the one moment the feature has to work. Lives beside `api-keys.json` in the data dir and
|
|
43
|
-
* holds the webhook secret, so it inherits that file's protection, no more and no less. */
|
|
44
|
-
const LINKS_PATH = dataPath('para-links.json');
|
|
45
|
-
/** `uid` is the shraga user whose API key opened this link. It is the OWNER of the entry — see
|
|
46
|
-
* `rememberLink`.
|
|
47
|
-
*
|
|
48
|
-
* `email` is that user's address, recorded so the PROACTIVE lane can answer "is this link's user
|
|
49
|
-
* an owner of this deployment?" — `OWNERS` is an email list, and a uid does not join to it. It is
|
|
50
|
-
* taken from `validateApiKey`, never from the request body. A link written before this field
|
|
51
|
-
* existed has no email and is therefore not an owner: it receives no notices until its next turn
|
|
52
|
-
* refreshes the entry. */
|
|
53
|
-
export type Link = ParaCallback & { convId: string; at: number; uid: string; email?: string };
|
|
54
|
-
|
|
55
|
-
export function loadLinks(): Record<string, Link> {
|
|
56
|
-
if (!existsSync(LINKS_PATH)) return {};
|
|
57
|
-
try { return JSON.parse(readFileSync(LINKS_PATH, 'utf-8')); } catch (err) {
|
|
58
|
-
console.warn('[para] links file unreadable, starting empty:', (err as Error).message);
|
|
59
|
-
return {};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/** Record (or refresh) a connection's callback.
|
|
63
|
-
*
|
|
64
|
-
* ONE USER OWNS A connId. `connId` is chosen by the caller, so without this an API key for user A
|
|
65
|
-
* could claim a connId already linked by user B and re-point every future PROACTIVE notice
|
|
66
|
-
* (deploy reports, self-upgrade outcomes) at A's URL + secret. An API key is already full agent
|
|
67
|
-
* access to its own user, so this is not a privilege boundary being invented — it is the one
|
|
68
|
-
* cross-user step that access does not otherwise imply, so it is refused rather than logged. */
|
|
69
|
-
function rememberLink(link: Link): void {
|
|
70
|
-
try {
|
|
71
|
-
const all = loadLinks();
|
|
72
|
-
const prev = all[link.connId];
|
|
73
|
-
if (prev?.uid && prev.uid !== link.uid) {
|
|
74
|
-
console.warn(`[para] refusing to re-link ${link.connId}: owned by another user`);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
all[link.connId] = link;
|
|
78
|
-
mkdirSync(dataPath(''), { recursive: true });
|
|
79
|
-
writeFileSync(LINKS_PATH, JSON.stringify(all, null, 2));
|
|
80
|
-
} catch (err) {
|
|
81
|
-
console.warn('[para] could not persist link:', (err as Error).message);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Run one turn, streaming into the para row. Errors settle the row visibly — the owner must never
|
|
86
|
-
* be left watching a "typing…" placeholder that will never resolve. */
|
|
87
|
-
async function runParaTurn(args: {
|
|
88
|
-
callback: ParaCallback; convId: string; msgId: string; sessionId: string; prompt: string;
|
|
89
|
-
uid: string; userEmail: string; sendSegments: boolean;
|
|
90
|
-
}): Promise<void> {
|
|
91
|
-
const { callback, convId, msgId, sessionId, prompt, uid, userEmail, sendSegments } = args;
|
|
92
|
-
const streamer = new ParaStreamer({ callback, convId, msgId, sendSegments });
|
|
93
|
-
const abortController = new AbortController();
|
|
94
|
-
|
|
95
|
-
// Lock origin is 'api': the union in sessions.ts is a closed set ('web'|'slack'|'scheduler'|
|
|
96
|
-
// 'api') and this is an authenticated API caller. Widening it just to label the medium would
|
|
97
|
-
// touch recovery and status code paths for no behavioural gain.
|
|
98
|
-
if (!acquireSessionLock(sessionId, 'api', abortController)) {
|
|
99
|
-
// sessionId === convId, so this is genuinely "you sent two messages into the same thread while
|
|
100
|
-
// the first was still running". Say so rather than dropping it silently.
|
|
101
|
-
await streamer.fail('That conversation is already processing a message — wait for it to finish.');
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
upsertSession(sessionId, prompt, { uid, email: userEmail });
|
|
105
|
-
appendMessage(sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'para' });
|
|
106
|
-
setRunStatus(sessionId, 'running', 'web');
|
|
107
|
-
|
|
108
|
-
const blocks: ConvBlock[] = [];
|
|
109
|
-
let text = '';
|
|
110
|
-
try {
|
|
111
|
-
for await (const ev of streamChat({
|
|
112
|
-
prompt, sessionId, uid, userEmail,
|
|
113
|
-
mcpServers: getMcpConfig(uid),
|
|
114
|
-
abortController,
|
|
115
|
-
context: { source: 'para', user: userEmail },
|
|
116
|
-
onPermissionRequest: async () => ({ allow: true }),
|
|
117
|
-
})) {
|
|
118
|
-
if (ev.type === 'text_delta') { text += ev.text; streamer.feed({ type: 'text_delta', text: ev.text }); }
|
|
119
|
-
else if (ev.type === 'tool_use') {
|
|
120
|
-
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
121
|
-
blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
122
|
-
streamer.feed({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
|
|
123
|
-
}
|
|
124
|
-
else if (ev.type === 'tool_result') {
|
|
125
|
-
blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
|
|
126
|
-
// The `running` → `completed`/`error` transition. Fed unconditionally; the streamer ignores
|
|
127
|
-
// it unless the receiver negotiated segments.
|
|
128
|
-
streamer.feed({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output, isError: ev.isError });
|
|
129
|
-
}
|
|
130
|
-
else if (ev.type === 'done') break;
|
|
131
|
-
else if (ev.type === 'error') {
|
|
132
|
-
if (text) { blocks.push({ type: 'text', text }); text = ''; }
|
|
133
|
-
blocks.push({ type: 'error', text: ev.message });
|
|
134
|
-
await streamer.fail(ev.message);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (text) blocks.push({ type: 'text', text });
|
|
139
|
-
await streamer.finish();
|
|
140
|
-
} catch (err) {
|
|
141
|
-
console.error('[para] turn failed:', (err as Error).message);
|
|
142
|
-
await streamer.fail((err as Error).message || 'agent error');
|
|
143
|
-
} finally {
|
|
144
|
-
// The transcript is persisted whatever happened, so the shraga UI and the next turn's context
|
|
145
|
-
// see the same history para saw.
|
|
146
|
-
if (blocks.length) appendMessage(sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
|
|
147
|
-
if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
let mounted = false;
|
|
152
|
-
let busSubscribed = false;
|
|
153
|
-
|
|
154
|
-
export const paraFeature: ServerFeature = {
|
|
155
|
-
name: 'para',
|
|
156
|
-
|
|
157
|
-
// No capability flag. `flags` is the seam's way to tell the CLIENT a surface exists, and nothing
|
|
158
|
-
// in the client gates on para — the lane is driven entirely by para.li calling in. Declaring one
|
|
159
|
-
// would be dead public surface on /api/features (slackFeature declares none for the same reason).
|
|
160
|
-
|
|
161
|
-
register(ctx: FeatureContext): void {
|
|
162
|
-
// Owner notices → the linked para conversations OF THIS DEPLOYMENT'S OWNERS. Keyed on the
|
|
163
|
-
// notice KIND, not the source, for the reason spelled out in slackFeature: self-upgrade emits
|
|
164
|
-
// under its own source and a source-gated subscriber silently dropped every one of them.
|
|
165
|
-
//
|
|
166
|
-
// WHY NOT `payload.owners`. That field is `{name?, slackId}[]` — the SLACK join, computed by
|
|
167
|
-
// `resolveOwners` as OWNERS ∩ contacts-that-have-a-Slack-id. A para link carries no Slack id,
|
|
168
|
-
// so the field is unmatchable here. Unfiltered, this loop posted every deploy / self-upgrade /
|
|
169
|
-
// data-sync report to EVERY entry in para-links.json — and any shraga user with an API key
|
|
170
|
-
// gets an entry on their first turn (`rememberLink`). The `uid` guard does not help: it stops
|
|
171
|
-
// STEALING another user's connId, not adding your own.
|
|
172
|
-
// The join that works is the one OWNERS is actually expressed in — the email of the API key
|
|
173
|
-
// that opened the link — checked with the same `isOwnerEmail` that backs `resolveOwners`.
|
|
174
|
-
if (!ctx.passive && !busSubscribed) {
|
|
175
|
-
busSubscribed = true;
|
|
176
|
-
subscribeEvents((evt) => {
|
|
177
|
-
const payload = evt.payload as DeployNotice;
|
|
178
|
-
if (payload?.kind !== 'deploy' || !payload.text) return;
|
|
179
|
-
for (const link of Object.values(loadLinks())) {
|
|
180
|
-
if (!isOwnerEmail(link.email)) continue;
|
|
181
|
-
postProactive({ url: link.url, secret: link.secret, connId: link.connId }, link.convId, payload.text)
|
|
182
|
-
.then((ok) => console.log(`[para] owner notice ${ok ? 'delivered' : 'FAILED'} → ${link.convId}`))
|
|
183
|
-
.catch((err) => console.warn('[para] owner notice failed:', (err as Error).message));
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (ctx.passive || mounted) return;
|
|
189
|
-
mounted = true;
|
|
190
|
-
|
|
191
|
-
ctx.app.post('/api/para/turn', (req, res) => {
|
|
192
|
-
const bearer = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '')?.[1];
|
|
193
|
-
const caller = bearer ? validateApiKey(bearer) : null;
|
|
194
|
-
if (!caller) return void res.status(401).json({ error: 'unauthorized' });
|
|
195
|
-
|
|
196
|
-
const { connId, convId, sessionId, msgId, prompt, callback, accepts } = req.body as {
|
|
197
|
-
connId?: string; convId?: string; sessionId?: string; msgId?: string; prompt?: string;
|
|
198
|
-
callback?: { url?: string; secret?: string };
|
|
199
|
-
/** Receiver capability negotiation. `'segments'` means "I can store and render structured
|
|
200
|
-
* tool segments" — see `ParaStreamerOptions.sendSegments`. ABSENT means no: a para-li that
|
|
201
|
-
* predates this field, and a lane (groups) that deliberately declines, both fall back to
|
|
202
|
-
* the flattened `_🔧 Tool_` markers in the text. */
|
|
203
|
-
accepts?: unknown;
|
|
204
|
-
};
|
|
205
|
-
if (!connId || !convId || !msgId || !prompt) return void res.status(400).json({ error: 'connId, convId, msgId and prompt are required' });
|
|
206
|
-
if (!callback?.url || !callback?.secret) return void res.status(400).json({ error: 'callback.url and callback.secret are required' });
|
|
207
|
-
try {
|
|
208
|
-
const u = new URL(callback.url);
|
|
209
|
-
// We hold the owner's credential and will POST to whatever this says, so it is validated
|
|
210
|
-
// here too rather than trusted because the request authenticated.
|
|
211
|
-
if (u.protocol !== 'https:' && u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') throw new Error('https required');
|
|
212
|
-
} catch { return void res.status(400).json({ error: 'callback.url must be a valid HTTPS URL' }); }
|
|
213
|
-
|
|
214
|
-
const cb: ParaCallback = { url: callback.url, secret: callback.secret, connId };
|
|
215
|
-
rememberLink({ ...cb, convId, at: Date.now(), uid: caller.uid, email: caller.email });
|
|
216
|
-
|
|
217
|
-
// ACCEPT, then run. The answer arrives on the callback, so holding this response open would
|
|
218
|
-
// only give para-li's trigger a socket to time out on.
|
|
219
|
-
res.json({ status: 'accepted', sessionId: sessionId || convId });
|
|
220
|
-
void runParaTurn({
|
|
221
|
-
callback: cb, convId, msgId, sessionId: sessionId || convId, prompt,
|
|
222
|
-
uid: caller.uid, userEmail: caller.email,
|
|
223
|
-
sendSegments: Array.isArray(accepts) && accepts.includes('segments'),
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
console.log('[para] turn ingress mounted at POST /api/para/turn');
|
|
228
|
-
},
|
|
229
|
-
};
|