@agenthippo-ai/agenthippo 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/analytics/sessionEvents.js +5 -0
- package/out/auth-broker/pkce.js +3 -1
- package/out/auth-broker/server.js +7 -1
- package/out/auth-broker/store.js +6 -5
- package/out/cli/commands/serve.js +131 -39
- package/out/cli/credential-context.js +16 -0
- package/out/cli/deploy-init.js +110 -9
- package/out/cli/index.js +9 -1
- package/out/cli/serve-hardening.js +158 -12
- package/out/cli/state-sync.js +317 -29
- package/out/headless/index.js +5 -2
- package/out/mcp.js +12 -3
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/shared/providers.js +3 -0
- package/out/test/analytics.test.js +68 -0
- package/out/test/auth-broker.test.js +6 -2
- package/out/test/credential-context.test.js +60 -0
- package/out/test/deploy-init.test.js +32 -1
- package/out/test/mcp.test.js +17 -1
- package/out/test/serve-hardening.test.js +107 -0
- package/out/test/serveClient.test.js +5 -2
- package/out/test/state-sync-peer.test.js +70 -1
- package/package.json +3 -4
package/out/cli/state-sync.js
CHANGED
|
@@ -2,37 +2,93 @@
|
|
|
2
2
|
/*---------------------------------------------------------------------------------------------
|
|
3
3
|
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
4
|
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
5
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
39
|
exports.isAwsCliAvailable = isAwsCliAvailable;
|
|
7
40
|
exports.resolveS3StateConfig = resolveS3StateConfig;
|
|
41
|
+
exports.resolveRestoreBootMode = resolveRestoreBootMode;
|
|
42
|
+
exports.safeSessionId = safeSessionId;
|
|
43
|
+
exports.sessionRemoteUri = sessionRemoteUri;
|
|
44
|
+
exports.shardBelongsToSession = shardBelongsToSession;
|
|
45
|
+
exports.hasLocalSessionState = hasLocalSessionState;
|
|
46
|
+
exports.listSessionShardFiles = listSessionShardFiles;
|
|
47
|
+
exports.stageSessionPushDir = stageSessionPushDir;
|
|
48
|
+
exports.mergeSessionStagingIntoState = mergeSessionStagingIntoState;
|
|
8
49
|
exports.restoreState = restoreState;
|
|
9
50
|
exports.pushState = pushState;
|
|
51
|
+
exports.pushSessionState = pushSessionState;
|
|
52
|
+
exports.ensureSessionLocal = ensureSessionLocal;
|
|
53
|
+
exports.clearRestoreAttemptCache = clearRestoreAttemptCache;
|
|
10
54
|
exports.makeStatePusher = makeStatePusher;
|
|
55
|
+
exports.providerSessionShardName = providerSessionShardName;
|
|
11
56
|
/**
|
|
12
|
-
* Object-store state sync (serve-v3 Q1
|
|
57
|
+
* Object-store state sync (serve-v3 Q1 + serve-v4 P1.2).
|
|
13
58
|
*
|
|
14
|
-
* Persists the durable state root
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* fleet replica can reload an affinity-owned session.
|
|
59
|
+
* Persists the durable state root to an S3-compatible object store so sessions
|
|
60
|
+
* survive scale-to-zero / a fresh instance (Cloudflare R2, MinIO, GCS, AWS S3)
|
|
61
|
+
* and so a sticky-routed fleet can restore-on-miss after resharding.
|
|
18
62
|
*
|
|
19
|
-
* Strategy
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
63
|
+
* Strategy:
|
|
64
|
+
* - Per-session async-delta push (debounced, off the response path) →
|
|
65
|
+
* s3://…/sessions/<sessionId>/
|
|
66
|
+
* - Restore-on-miss before a turn when the session is not local
|
|
67
|
+
* - Optional boot restore of the whole prefix (`AGENTHIPPO_STATE_RESTORE_BOOT=all`)
|
|
68
|
+
* - SIGTERM flush of pending session pushes
|
|
69
|
+
*
|
|
70
|
+
* Uses `aws s3 sync` (S3-compatible via --endpoint-url). No SDK in the binary.
|
|
71
|
+
* Last-write-wins per session prefix — no CAS. Mid-reshard may lose one turn of
|
|
72
|
+
* context; sticky routing makes that rare.
|
|
23
73
|
*
|
|
24
74
|
* Config (no-op unless AGENTHIPPO_STATE_S3_URI is set):
|
|
25
|
-
* AGENTHIPPO_STATE_S3_URI
|
|
26
|
-
* AGENTHIPPO_STATE_S3_ENDPOINT
|
|
27
|
-
*
|
|
75
|
+
* AGENTHIPPO_STATE_S3_URI s3://bucket/prefix
|
|
76
|
+
* AGENTHIPPO_STATE_S3_ENDPOINT http://minio:9000 (non-AWS S3)
|
|
77
|
+
* AGENTHIPPO_STATE_RESTORE_BOOT all|none (default all for single-instance compat;
|
|
78
|
+
* multi-replica templates set none)
|
|
79
|
+
* AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION
|
|
28
80
|
*/
|
|
81
|
+
const crypto_1 = require("crypto");
|
|
29
82
|
const child_process_1 = require("child_process");
|
|
83
|
+
const fsp = __importStar(require("fs/promises"));
|
|
84
|
+
const path = __importStar(require("path"));
|
|
30
85
|
const util_1 = require("util");
|
|
31
86
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
87
|
+
/** In-process cache: skip repeat S3 pulls for the same session on this replica. */
|
|
88
|
+
const restoreAttempted = new Set();
|
|
32
89
|
/**
|
|
33
90
|
* Verify the `aws` CLI is callable. State sync shells out to it, so when S3 is
|
|
34
91
|
* configured a missing binary means silent data loss — callers should fail fast.
|
|
35
|
-
* Resolves true when available, false otherwise.
|
|
36
92
|
*/
|
|
37
93
|
async function isAwsCliAvailable() {
|
|
38
94
|
try {
|
|
@@ -54,6 +110,27 @@ function resolveS3StateConfig(env = process.env) {
|
|
|
54
110
|
endpoint: env.AGENTHIPPO_STATE_S3_ENDPOINT?.trim() || undefined,
|
|
55
111
|
};
|
|
56
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Boot restore mode. Default `all` preserves single-instance behavior.
|
|
115
|
+
* Multi-replica templates set `none` and rely on restore-on-miss (P1.2).
|
|
116
|
+
*/
|
|
117
|
+
function resolveRestoreBootMode(env = process.env) {
|
|
118
|
+
const raw = env.AGENTHIPPO_STATE_RESTORE_BOOT?.trim().toLowerCase();
|
|
119
|
+
if (raw === 'none') {
|
|
120
|
+
return 'none';
|
|
121
|
+
}
|
|
122
|
+
return 'all';
|
|
123
|
+
}
|
|
124
|
+
/** Sanitize a client session id for use as an S3 path segment / local dir name. */
|
|
125
|
+
function safeSessionId(sessionId) {
|
|
126
|
+
const trimmed = sessionId.trim();
|
|
127
|
+
const safe = trimmed.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_').replace(/\.\./g, '_').slice(0, 160);
|
|
128
|
+
return safe || 'session';
|
|
129
|
+
}
|
|
130
|
+
/** Remote prefix for one session: s3://bucket/prefix/sessions/<id> */
|
|
131
|
+
function sessionRemoteUri(cfg, sessionId) {
|
|
132
|
+
return `${cfg.uri}/sessions/${safeSessionId(sessionId)}`;
|
|
133
|
+
}
|
|
57
134
|
function syncArgs(cfg, from, to) {
|
|
58
135
|
const args = ['s3', 'sync', from, to, '--no-progress'];
|
|
59
136
|
if (cfg.endpoint) {
|
|
@@ -61,6 +138,126 @@ function syncArgs(cfg, from, to) {
|
|
|
61
138
|
}
|
|
62
139
|
return args;
|
|
63
140
|
}
|
|
141
|
+
function attemptKey(stateDir, sessionId) {
|
|
142
|
+
return `${path.resolve(stateDir)}\0${sessionId}`;
|
|
143
|
+
}
|
|
144
|
+
/** Conversation key format: engine:chatSessionId:pack:signature */
|
|
145
|
+
function shardBelongsToSession(shardKey, chatSessionId, value) {
|
|
146
|
+
if (value?.chatSessionId && value.chatSessionId === chatSessionId) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
const parts = shardKey.split(':');
|
|
150
|
+
return parts.length >= 2 && parts[1] === chatSessionId;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* True when this replica already has provider-session state for the chat session.
|
|
154
|
+
* Used to skip restore-on-miss on the hot path.
|
|
155
|
+
*/
|
|
156
|
+
async function hasLocalSessionState(stateDir, sessionId) {
|
|
157
|
+
const dir = path.join(stateDir, 'provider-sessions');
|
|
158
|
+
let names;
|
|
159
|
+
try {
|
|
160
|
+
names = await fsp.readdir(dir);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
if (!name.endsWith('.json')) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const raw = await fsp.readFile(path.join(dir, name), 'utf8');
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
if (typeof parsed.key === 'string' && shardBelongsToSession(parsed.key, sessionId, parsed.value)) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// skip unreadable
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
/** Collect provider-session shard filenames that belong to this chat session. */
|
|
183
|
+
async function listSessionShardFiles(stateDir, sessionId) {
|
|
184
|
+
const dir = path.join(stateDir, 'provider-sessions');
|
|
185
|
+
let names;
|
|
186
|
+
try {
|
|
187
|
+
names = await fsp.readdir(dir);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const name of names) {
|
|
194
|
+
if (!name.endsWith('.json')) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const raw = await fsp.readFile(path.join(dir, name), 'utf8');
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
if (typeof parsed.key === 'string' && shardBelongsToSession(parsed.key, sessionId, parsed.value)) {
|
|
201
|
+
out.push(name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// skip
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
async function pathExists(p) {
|
|
211
|
+
try {
|
|
212
|
+
await fsp.access(p);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function copyDirIfExists(src, dest) {
|
|
220
|
+
if (!(await pathExists(src))) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await fsp.mkdir(dest, { recursive: true });
|
|
224
|
+
await fsp.cp(src, dest, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Build a local staging tree for session S:
|
|
228
|
+
* provider-sessions/<shards for S>
|
|
229
|
+
* agents/ (replica snapshot — sticky-by-user keeps this scoped)
|
|
230
|
+
*/
|
|
231
|
+
async function stageSessionPushDir(stateDir, sessionId, stagingDir) {
|
|
232
|
+
await fsp.rm(stagingDir, { recursive: true, force: true });
|
|
233
|
+
await fsp.mkdir(stagingDir, { recursive: true });
|
|
234
|
+
const shards = await listSessionShardFiles(stateDir, sessionId);
|
|
235
|
+
const psDest = path.join(stagingDir, 'provider-sessions');
|
|
236
|
+
if (shards.length > 0) {
|
|
237
|
+
await fsp.mkdir(psDest, { recursive: true });
|
|
238
|
+
for (const name of shards) {
|
|
239
|
+
await fsp.copyFile(path.join(stateDir, 'provider-sessions', name), path.join(psDest, name));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
await copyDirIfExists(path.join(stateDir, 'agents'), path.join(stagingDir, 'agents'));
|
|
243
|
+
const hasAgents = await pathExists(path.join(stagingDir, 'agents'));
|
|
244
|
+
return shards.length > 0 || hasAgents;
|
|
245
|
+
}
|
|
246
|
+
/** Merge a restored staging tree back into stateDir. */
|
|
247
|
+
async function mergeSessionStagingIntoState(stagingDir, stateDir) {
|
|
248
|
+
const psSrc = path.join(stagingDir, 'provider-sessions');
|
|
249
|
+
if (await pathExists(psSrc)) {
|
|
250
|
+
const psDest = path.join(stateDir, 'provider-sessions');
|
|
251
|
+
await fsp.mkdir(psDest, { recursive: true });
|
|
252
|
+
for (const name of await fsp.readdir(psSrc)) {
|
|
253
|
+
if (!name.endsWith('.json')) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
await fsp.copyFile(path.join(psSrc, name), path.join(psDest, name));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
await copyDirIfExists(path.join(stagingDir, 'agents'), path.join(stateDir, 'agents'));
|
|
260
|
+
}
|
|
64
261
|
/** Download state from the bucket into stateDir on boot. An empty bucket is a no-op. */
|
|
65
262
|
async function restoreState(cfg, stateDir, log) {
|
|
66
263
|
try {
|
|
@@ -68,46 +265,130 @@ async function restoreState(cfg, stateDir, log) {
|
|
|
68
265
|
log?.(`State restored from ${cfg.uri}`);
|
|
69
266
|
}
|
|
70
267
|
catch (err) {
|
|
71
|
-
// First boot / empty bucket / transient — don't block startup.
|
|
72
268
|
log?.(`State restore skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
73
269
|
}
|
|
74
270
|
}
|
|
75
|
-
/** Upload changed files from stateDir to the bucket (delta). */
|
|
271
|
+
/** Upload changed files from stateDir to the bucket (full-tree delta — legacy / flush fallback). */
|
|
76
272
|
async function pushState(cfg, stateDir, log) {
|
|
77
273
|
try {
|
|
78
274
|
await execFileAsync('aws', syncArgs(cfg, stateDir, cfg.uri));
|
|
79
275
|
}
|
|
80
276
|
catch (err) {
|
|
81
|
-
// A failed push means sessions aren't durable — surface it unconditionally,
|
|
82
|
-
// not just under --verbose, so the operator notices state isn't persisting.
|
|
83
277
|
console.error(`[state-sync] push to ${cfg.uri} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
84
278
|
log?.(`State push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
279
|
}
|
|
86
280
|
}
|
|
281
|
+
/** Push one session's scoped tree to s3://…/sessions/<id>/. */
|
|
282
|
+
async function pushSessionState(cfg, stateDir, sessionId, log) {
|
|
283
|
+
const safe = safeSessionId(sessionId);
|
|
284
|
+
const staging = path.join(stateDir, '.push-staging', safe);
|
|
285
|
+
try {
|
|
286
|
+
const hasContent = await stageSessionPushDir(stateDir, sessionId, staging);
|
|
287
|
+
if (!hasContent) {
|
|
288
|
+
log?.(`State push skipped for session ${safe}: nothing local`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const remote = sessionRemoteUri(cfg, sessionId);
|
|
292
|
+
await execFileAsync('aws', syncArgs(cfg, staging, remote));
|
|
293
|
+
log?.(`State pushed session ${safe} → ${remote}`);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
console.error(`[state-sync] push session ${safe} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
297
|
+
log?.(`State push failed for ${safe}: ${err instanceof Error ? err.message : String(err)}`);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* On a request whose session_id has no local provider-session state, pull
|
|
305
|
+
* s3://…/sessions/<id>/ before runner.run(). No-ops if already local or already
|
|
306
|
+
* attempted this process (avoids re-pulling brand-new sessions every turn).
|
|
307
|
+
*/
|
|
308
|
+
async function ensureSessionLocal(cfg, stateDir, sessionId, log) {
|
|
309
|
+
const key = attemptKey(stateDir, sessionId);
|
|
310
|
+
if (restoreAttempted.has(key)) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (await hasLocalSessionState(stateDir, sessionId)) {
|
|
314
|
+
restoreAttempted.add(key);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const safe = safeSessionId(sessionId);
|
|
318
|
+
const staging = path.join(stateDir, '.restore-staging', safe);
|
|
319
|
+
try {
|
|
320
|
+
await fsp.rm(staging, { recursive: true, force: true });
|
|
321
|
+
await fsp.mkdir(staging, { recursive: true });
|
|
322
|
+
const remote = sessionRemoteUri(cfg, sessionId);
|
|
323
|
+
await execFileAsync('aws', syncArgs(cfg, remote, staging));
|
|
324
|
+
await mergeSessionStagingIntoState(staging, stateDir);
|
|
325
|
+
log?.(`State restored session ${safe} from ${remote}`);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
// Empty prefix / first turn / transient — continue; runner creates fresh state.
|
|
329
|
+
log?.(`State restore-on-miss skipped for ${safe}: ${err instanceof Error ? err.message : String(err)}`);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
restoreAttempted.add(key);
|
|
333
|
+
await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Test helper — clear the restore-attempted cache. */
|
|
337
|
+
function clearRestoreAttemptCache() {
|
|
338
|
+
restoreAttempted.clear();
|
|
339
|
+
}
|
|
87
340
|
/**
|
|
88
|
-
* Debounced, non-overlapping pusher.
|
|
89
|
-
*
|
|
341
|
+
* Debounced, non-overlapping per-session pusher.
|
|
342
|
+
* `schedule(sessionId)` after each turn; `flush()` awaits pending pushes (SIGTERM).
|
|
90
343
|
*/
|
|
91
344
|
function makeStatePusher(cfg, stateDir, log, waitMs = 1000) {
|
|
92
345
|
let timer = null;
|
|
93
346
|
let running = false;
|
|
94
|
-
let pending = false;
|
|
95
347
|
let inflightPromise = null;
|
|
348
|
+
const pendingSessions = new Set();
|
|
349
|
+
let pendingFull = false;
|
|
96
350
|
const runNow = () => {
|
|
97
351
|
running = true;
|
|
98
|
-
|
|
99
|
-
|
|
352
|
+
const sessions = [...pendingSessions];
|
|
353
|
+
const doFull = pendingFull;
|
|
354
|
+
pendingSessions.clear();
|
|
355
|
+
pendingFull = false;
|
|
356
|
+
inflightPromise = (async () => {
|
|
357
|
+
if (doFull) {
|
|
358
|
+
await pushState(cfg, stateDir, log);
|
|
359
|
+
}
|
|
360
|
+
for (const id of sessions) {
|
|
361
|
+
await pushSessionState(cfg, stateDir, id, log);
|
|
362
|
+
}
|
|
363
|
+
})()
|
|
364
|
+
.catch((err) => {
|
|
365
|
+
console.error(`[state-sync] push batch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
366
|
+
})
|
|
367
|
+
.finally(() => {
|
|
100
368
|
running = false;
|
|
101
369
|
inflightPromise = null;
|
|
102
|
-
if (
|
|
103
|
-
schedule()
|
|
370
|
+
if (pendingSessions.size > 0 || pendingFull) {
|
|
371
|
+
// Re-arm debounce without treating this as a legacy full-push schedule().
|
|
372
|
+
if (!timer) {
|
|
373
|
+
timer = setTimeout(() => {
|
|
374
|
+
timer = null;
|
|
375
|
+
void runNow();
|
|
376
|
+
}, waitMs);
|
|
377
|
+
timer.unref?.();
|
|
378
|
+
}
|
|
104
379
|
}
|
|
105
380
|
});
|
|
106
381
|
return inflightPromise;
|
|
107
382
|
};
|
|
108
|
-
const schedule = () => {
|
|
383
|
+
const schedule = (sessionId) => {
|
|
384
|
+
if (sessionId?.trim()) {
|
|
385
|
+
pendingSessions.add(sessionId.trim());
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Legacy no-arg call → full-tree push (boot-compat / callers that omit id).
|
|
389
|
+
pendingFull = true;
|
|
390
|
+
}
|
|
109
391
|
if (running) {
|
|
110
|
-
pending = true;
|
|
111
392
|
return;
|
|
112
393
|
}
|
|
113
394
|
if (timer) {
|
|
@@ -124,12 +405,19 @@ function makeStatePusher(cfg, stateDir, log, waitMs = 1000) {
|
|
|
124
405
|
clearTimeout(timer);
|
|
125
406
|
timer = null;
|
|
126
407
|
}
|
|
127
|
-
// Wait for any in-flight push to complete before the final push,
|
|
128
|
-
// so SIGTERM never runs two concurrent aws s3 sync processes.
|
|
129
408
|
if (inflightPromise) {
|
|
130
409
|
await inflightPromise;
|
|
131
410
|
}
|
|
132
|
-
|
|
411
|
+
if (pendingSessions.size > 0 || pendingFull) {
|
|
412
|
+
await runNow();
|
|
413
|
+
if (inflightPromise) {
|
|
414
|
+
await inflightPromise;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
133
417
|
};
|
|
134
418
|
return { schedule, flush };
|
|
135
419
|
}
|
|
420
|
+
/** Stable hash helper exposed for tests (matches DirectorySessionStore naming). */
|
|
421
|
+
function providerSessionShardName(conversationKey) {
|
|
422
|
+
return `${(0, crypto_1.createHash)('sha256').update(conversationKey).digest('hex').slice(0, 40)}.json`;
|
|
423
|
+
}
|
package/out/headless/index.js
CHANGED
|
@@ -283,8 +283,11 @@ function createHeadlessRunner(config) {
|
|
|
283
283
|
}
|
|
284
284
|
return customEngine;
|
|
285
285
|
};
|
|
286
|
-
// Shared session event writer for Layer 2 analytics
|
|
287
|
-
|
|
286
|
+
// Shared session event writer for Layer 2 analytics. Rooted under storagePath (not the
|
|
287
|
+
// os.homedir() default) so tool-call/warrant events land on the same durable volume as
|
|
288
|
+
// session state — a container's home directory is ephemeral, storagePath honors
|
|
289
|
+
// AGENTHIPPO_HOME / --state-dir and is what actually survives a restart or scale event.
|
|
290
|
+
const sessionEventWriter = new sessionEvents_1.SessionEventWriter(path.join(config.storagePath, 'analytics', 'sessions'));
|
|
288
291
|
return {
|
|
289
292
|
async run(options) {
|
|
290
293
|
const result = {
|
package/out/mcp.js
CHANGED
|
@@ -271,8 +271,15 @@ function resolveMcpEnvVar(varName, envSources = {}) {
|
|
|
271
271
|
const aliases = getDataTokenEnvAliases();
|
|
272
272
|
const isDataTokenVar = varName === 'dataToken' || aliases.includes(varName);
|
|
273
273
|
if (tokenFile && isDataTokenVar) {
|
|
274
|
-
// Token lives in the per-turn file (AGENTHIPPO_DATA_TOKEN_FILE);
|
|
275
|
-
|
|
274
|
+
// Token lives in the per-turn file (AGENTHIPPO_DATA_TOKEN_FILE); never bake the OBO token
|
|
275
|
+
// into spawn env. The explicit ${credential:dataToken} placeholder resolves to empty
|
|
276
|
+
// (AgentHippo-aware servers read the file). A plain ${env:ALIAS} reference falls through to
|
|
277
|
+
// any operator-provided ambient value so a stock server that reads the var directly still
|
|
278
|
+
// works, instead of being clobbered to an empty string.
|
|
279
|
+
if (varName === 'dataToken') {
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
return process.env[varName] ?? envSources[varName] ?? '';
|
|
276
283
|
}
|
|
277
284
|
if (varName === 'dataToken') {
|
|
278
285
|
return cred?.dataToken?.trim() ?? '';
|
|
@@ -465,7 +472,9 @@ function generateCodexMcpConfig(servers, options) {
|
|
|
465
472
|
// Use separate [mcp_servers.name.env] section — codex binary requires this form
|
|
466
473
|
lines.push('', `[mcp_servers.${s.name}.env]`);
|
|
467
474
|
for (const [k, v] of Object.entries(s.env)) {
|
|
468
|
-
|
|
475
|
+
// Quote the key: env-var names may contain '.', spaces, etc. that are not valid
|
|
476
|
+
// TOML bare keys (a bare `my.token` would parse as a nested table).
|
|
477
|
+
lines.push(`${JSON.stringify(k)} = ${JSON.stringify(v)}`);
|
|
469
478
|
}
|
|
470
479
|
}
|
|
471
480
|
return lines.join('\n');
|
|
@@ -96,13 +96,19 @@ async function handleRemoteServeChat(deps, args) {
|
|
|
96
96
|
try {
|
|
97
97
|
syncService.incrementActiveAgentTurns();
|
|
98
98
|
await observableEmitter.emitTurnStart();
|
|
99
|
-
await (0, serveClient_js_1.streamRemoteServeChat)({
|
|
99
|
+
const result = await (0, serveClient_js_1.streamRemoteServeChat)({
|
|
100
100
|
baseUrl: remoteUrl,
|
|
101
101
|
message,
|
|
102
102
|
sessionId: remoteSessionId,
|
|
103
103
|
signal: abortController.signal,
|
|
104
104
|
auth,
|
|
105
105
|
}, observableEmitter);
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
// streamRemoteServeChat already surfaced the failure to the emitter (error + done);
|
|
108
|
+
// record the turn as failed and propagate to the caller without re-emitting.
|
|
109
|
+
await observableEmitter.emitTurnEnd(false);
|
|
110
|
+
return { error: result.error ?? 'Remote serve failed' };
|
|
111
|
+
}
|
|
106
112
|
await observableEmitter.emitTurnEnd(true);
|
|
107
113
|
return { streaming: true };
|
|
108
114
|
}
|
|
@@ -77,7 +77,7 @@ async function streamRemoteServeChat(options, emitter) {
|
|
|
77
77
|
const message = err instanceof Error ? err.message : 'Remote serve connection failed';
|
|
78
78
|
await emitter.error(message);
|
|
79
79
|
await emitter.done();
|
|
80
|
-
return;
|
|
80
|
+
return { ok: false, error: message };
|
|
81
81
|
}
|
|
82
82
|
if (!response.ok) {
|
|
83
83
|
clearTimers();
|
|
@@ -93,29 +93,39 @@ async function streamRemoteServeChat(options, emitter) {
|
|
|
93
93
|
}
|
|
94
94
|
await emitter.error(errorMessage);
|
|
95
95
|
await emitter.done();
|
|
96
|
-
return;
|
|
96
|
+
return { ok: false, error: errorMessage };
|
|
97
97
|
}
|
|
98
98
|
if (!response.body) {
|
|
99
99
|
clearTimers();
|
|
100
|
-
|
|
100
|
+
const message = 'Remote serve returned empty body';
|
|
101
|
+
await emitter.error(message);
|
|
101
102
|
await emitter.done();
|
|
102
|
-
return;
|
|
103
|
+
return { ok: false, error: message };
|
|
103
104
|
}
|
|
104
105
|
try {
|
|
105
|
-
await consumeServeSse(response.body, emitter, resetIdle);
|
|
106
|
+
const streamError = await consumeServeSse(response.body, emitter, resetIdle);
|
|
107
|
+
return streamError ? { ok: false, error: streamError } : { ok: true };
|
|
106
108
|
}
|
|
107
109
|
finally {
|
|
108
110
|
clearTimers();
|
|
109
111
|
}
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Consume the SSE stream. Returns the message of an `error` event if one was seen
|
|
115
|
+
* (the turn failed even though the stream ended cleanly), otherwise undefined.
|
|
116
|
+
*/
|
|
111
117
|
async function consumeServeSse(body, emitter, resetIdle) {
|
|
112
118
|
const reader = body.getReader();
|
|
113
119
|
const decoder = new TextDecoder();
|
|
114
120
|
let buffer = '';
|
|
115
121
|
let doneEmitted = false;
|
|
122
|
+
let streamError;
|
|
116
123
|
const markDone = () => {
|
|
117
124
|
doneEmitted = true;
|
|
118
125
|
};
|
|
126
|
+
const markError = (message) => {
|
|
127
|
+
streamError = message;
|
|
128
|
+
};
|
|
119
129
|
try {
|
|
120
130
|
while (true) {
|
|
121
131
|
const { value, done } = await reader.read();
|
|
@@ -125,24 +135,25 @@ async function consumeServeSse(body, emitter, resetIdle) {
|
|
|
125
135
|
// Any data on the wire means the turn is progressing — push the idle window out.
|
|
126
136
|
resetIdle();
|
|
127
137
|
buffer += decoder.decode(value, { stream: true });
|
|
128
|
-
buffer = await drainServeSseBuffer(buffer, emitter, markDone);
|
|
138
|
+
buffer = await drainServeSseBuffer(buffer, emitter, markDone, markError);
|
|
129
139
|
if (doneEmitted) {
|
|
130
|
-
return;
|
|
140
|
+
return streamError;
|
|
131
141
|
}
|
|
132
142
|
}
|
|
133
143
|
buffer += decoder.decode();
|
|
134
144
|
if (buffer.trim()) {
|
|
135
|
-
await drainServeSseBuffer(`${buffer}\n`, emitter, markDone);
|
|
145
|
+
await drainServeSseBuffer(`${buffer}\n`, emitter, markDone, markError);
|
|
136
146
|
}
|
|
137
147
|
if (!doneEmitted) {
|
|
138
148
|
await emitter.done();
|
|
139
149
|
}
|
|
150
|
+
return streamError;
|
|
140
151
|
}
|
|
141
152
|
finally {
|
|
142
153
|
reader.releaseLock();
|
|
143
154
|
}
|
|
144
155
|
}
|
|
145
|
-
async function drainServeSseBuffer(buffer, emitter, markDone) {
|
|
156
|
+
async function drainServeSseBuffer(buffer, emitter, markDone, markError) {
|
|
146
157
|
const lines = buffer.split('\n');
|
|
147
158
|
const remainder = lines.pop() ?? '';
|
|
148
159
|
for (const line of lines) {
|
|
@@ -160,7 +171,7 @@ async function drainServeSseBuffer(buffer, emitter, markDone) {
|
|
|
160
171
|
catch {
|
|
161
172
|
continue;
|
|
162
173
|
}
|
|
163
|
-
const finished = await dispatchServeEvent(event, emitter);
|
|
174
|
+
const finished = await dispatchServeEvent(event, emitter, markError);
|
|
164
175
|
if (finished) {
|
|
165
176
|
markDone();
|
|
166
177
|
return '';
|
|
@@ -168,7 +179,7 @@ async function drainServeSseBuffer(buffer, emitter, markDone) {
|
|
|
168
179
|
}
|
|
169
180
|
return remainder;
|
|
170
181
|
}
|
|
171
|
-
async function dispatchServeEvent(event, emitter) {
|
|
182
|
+
async function dispatchServeEvent(event, emitter, markError) {
|
|
172
183
|
const type = typeof event.type === 'string' ? event.type : '';
|
|
173
184
|
switch (type) {
|
|
174
185
|
case 'thinking':
|
|
@@ -204,9 +215,12 @@ async function dispatchServeEvent(event, emitter) {
|
|
|
204
215
|
}
|
|
205
216
|
return false;
|
|
206
217
|
}
|
|
207
|
-
case 'error':
|
|
208
|
-
|
|
218
|
+
case 'error': {
|
|
219
|
+
const message = typeof event.message === 'string' ? event.message : 'Remote serve error';
|
|
220
|
+
markError(message);
|
|
221
|
+
await emitter.error(message);
|
|
209
222
|
return false;
|
|
223
|
+
}
|
|
210
224
|
case 'done':
|
|
211
225
|
await emitter.done();
|
|
212
226
|
return true;
|
package/out/shared/providers.js
CHANGED
|
@@ -79,6 +79,7 @@ exports.PROVIDERS = {
|
|
|
79
79
|
description: 'local adapter for agents to work with Spotlight and all models from OpenAI, Anthropic, Ollama etc.',
|
|
80
80
|
showInAddApiKeyUI: true,
|
|
81
81
|
isGateway: true, // Can route to any backend
|
|
82
|
+
deferMissingApiKey: true, // base-url configured; key may be empty in dev
|
|
82
83
|
},
|
|
83
84
|
ollama: {
|
|
84
85
|
id: 'ollama',
|
|
@@ -102,6 +103,8 @@ exports.PROVIDERS = {
|
|
|
102
103
|
showInAddApiKeyUI: false,
|
|
103
104
|
isGateway: true,
|
|
104
105
|
staticHeaders: { 'x-agenthippo-source': 'chat-ui' },
|
|
106
|
+
credentialRefreshCommand: 'agentide.auth.refreshToken', // refresh stale sign-in JWT on 401
|
|
107
|
+
deferMissingApiKey: true, // key is the sign-in JWT; route branch prompts sign-in
|
|
105
108
|
},
|
|
106
109
|
};
|
|
107
110
|
// -----------------------------------------------------------------------------
|