@anna-ai/cli 0.1.9 → 0.1.11
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 +64 -3
- package/dist/{bridge-BDBECvV1.js → bridge-BIO7ilgO.js} +1 -1
- package/dist/{bridge-CBcQUQGU.js → bridge-Cpm3D2Wk.js} +1 -1
- package/dist/cli.js +47 -8
- package/dist/credentials-ggdaz_-7.js +122 -0
- package/dist/dev-BPIUX2Nh.js +366 -0
- package/dist/{doctor-BmR0POfL.js → doctor-R1pjmBDG.js} +2 -2
- package/dist/login-D8cmvBb6.js +102 -0
- package/dist/logout-P6L9VU4W.js +23 -0
- package/dist/server-Cd5Lo-2v.js +678 -0
- package/dist/test/index.js +45 -1
- package/dist/whoami-jqlQwe7Z.js +43 -0
- package/package.json +2 -1
- package/dist/dev-D-Tru6gP.js +0 -163
- package/dist/server-gl345fFN.js +0 -261
- /package/dist/{fixture-BGjMtqWA.js → fixture-RceUUd84.js} +0 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { canonicalHost, getAccount } from "./credentials-ggdaz_-7.js";
|
|
2
|
+
import { dirname, join, normalize, resolve } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { createReadStream, existsSync, readFileSync, statSync, watch } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
10
|
+
|
|
11
|
+
//#region src/harness/llm-bridge.ts
|
|
12
|
+
var LlmBridge = class {
|
|
13
|
+
mintedAuto = new Map();
|
|
14
|
+
mintedAgent = new Map();
|
|
15
|
+
mocks = [];
|
|
16
|
+
streamCounter = 0;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
if (opts.mode === "mock" && opts.mockFile) {
|
|
20
|
+
const path = resolve(opts.mockFile);
|
|
21
|
+
if (existsSync(path)) {
|
|
22
|
+
const lines = readFileSync(path, "utf8").split(/\r?\n/);
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if (!line.trim() || line.startsWith("#")) continue;
|
|
25
|
+
try {
|
|
26
|
+
this.mocks.push(JSON.parse(line));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Returns true iff this bridge handles `(ns, method)` (i.e. the harness
|
|
33
|
+
* should NOT forward it to the in-process Python dispatcher). */
|
|
34
|
+
static handles(ns, method) {
|
|
35
|
+
if (ns === "llm" && method === "complete") return true;
|
|
36
|
+
if (ns === "agent" && method.startsWith("session.")) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/** Resolve the active account or throw with a friendly message. */
|
|
40
|
+
account() {
|
|
41
|
+
const acc = getAccount(this.opts.account);
|
|
42
|
+
if (!acc) throw new Error("no PAT on disk — run `anna-app login --host <nexus-url>` first (or use `--no-llm` / `--mock-llm <fixture>` to develop offline)");
|
|
43
|
+
if (acc.expires_at && acc.expires_at < Math.floor(Date.now() / 1e3)) throw new Error("PAT expired — run `anna-app login` again");
|
|
44
|
+
return acc;
|
|
45
|
+
}
|
|
46
|
+
/** Mint (or reuse) an `app_session_token` for kind=complete (per window). */
|
|
47
|
+
async mintComplete(windowUuid) {
|
|
48
|
+
const cached = this.mintedAuto.get(windowUuid);
|
|
49
|
+
if (cached && cached.expiresAt - 30 > Math.floor(Date.now() / 1e3)) return cached;
|
|
50
|
+
const acc = this.account();
|
|
51
|
+
const body = {
|
|
52
|
+
pat: acc.pat,
|
|
53
|
+
kind: "complete",
|
|
54
|
+
app_id: this.opts.appId ?? null
|
|
55
|
+
};
|
|
56
|
+
if (!body.app_id) body.app_id = 1;
|
|
57
|
+
const minted = await this.callMint(acc.host, body);
|
|
58
|
+
const ms = {
|
|
59
|
+
appSessionToken: minted.app_session_token,
|
|
60
|
+
appSessionUuid: minted.app_session_uuid,
|
|
61
|
+
expiresAt: Math.floor(Date.now() / 1e3) + (minted.expires_in || 600),
|
|
62
|
+
isAgent: false,
|
|
63
|
+
submode: null
|
|
64
|
+
};
|
|
65
|
+
this.mintedAuto.set(windowUuid, ms);
|
|
66
|
+
return ms;
|
|
67
|
+
}
|
|
68
|
+
/** Mint a kind=agent session — called from `agent.session.create`. */
|
|
69
|
+
async mintAgent(args) {
|
|
70
|
+
const acc = this.account();
|
|
71
|
+
const submode = args.submode ?? "auto";
|
|
72
|
+
const body = {
|
|
73
|
+
pat: acc.pat,
|
|
74
|
+
kind: "agent",
|
|
75
|
+
submode,
|
|
76
|
+
fixed_client_id: args.fixed_client_id ?? args.fixedClientId ?? null,
|
|
77
|
+
app_id: this.opts.appId ?? 1,
|
|
78
|
+
label: args.label ?? "anna-app dev",
|
|
79
|
+
quota_caps: args.quotaCaps ?? null
|
|
80
|
+
};
|
|
81
|
+
const minted = await this.callMint(acc.host, body);
|
|
82
|
+
const ms = {
|
|
83
|
+
appSessionToken: minted.app_session_token,
|
|
84
|
+
appSessionUuid: minted.app_session_uuid,
|
|
85
|
+
expiresAt: Math.floor(Date.now() / 1e3) + (minted.expires_in || 600),
|
|
86
|
+
isAgent: true,
|
|
87
|
+
submode
|
|
88
|
+
};
|
|
89
|
+
this.mintedAgent.set(ms.appSessionUuid, ms);
|
|
90
|
+
return ms;
|
|
91
|
+
}
|
|
92
|
+
async callMint(host, body) {
|
|
93
|
+
const url = `${canonicalHost(host)}/api/v1/anna-apps/dev/session/mint`;
|
|
94
|
+
const res = await fetch(url, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify(body)
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const text = await res.text().catch(() => "");
|
|
101
|
+
throw new Error(`session.mint failed: HTTP ${res.status} ${text}`);
|
|
102
|
+
}
|
|
103
|
+
return await res.json();
|
|
104
|
+
}
|
|
105
|
+
/** Public entry — invoked by harness `proxyCall` for llm.* / agent.*. */
|
|
106
|
+
async dispatch(args) {
|
|
107
|
+
if (this.opts.mode === "off") return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: {
|
|
110
|
+
code: "llm_disabled",
|
|
111
|
+
message: "harness started with --no-llm"
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
if (this.opts.mode === "mock") return this.dispatchMock(args);
|
|
115
|
+
try {
|
|
116
|
+
return await this.dispatchReal(args);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: "transport",
|
|
122
|
+
message: e.message
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async dispatchMock(args) {
|
|
128
|
+
const content = String(args.args?.content ?? args.args?.messages ?? "");
|
|
129
|
+
const entry = this.mocks.find((m) => m.ns === args.ns && m.method === args.method && (!m.match?.contentIncludes || content.includes(m.match.contentIncludes))) ?? this.mocks.find((m) => m.ns === args.ns && m.method === args.method);
|
|
130
|
+
if (!entry) return {
|
|
131
|
+
ok: true,
|
|
132
|
+
result: args.ns === "llm" ? {
|
|
133
|
+
role: "assistant",
|
|
134
|
+
content: {
|
|
135
|
+
type: "text",
|
|
136
|
+
text: "(mock) no fixture matched"
|
|
137
|
+
},
|
|
138
|
+
model: "mock-model",
|
|
139
|
+
stopReason: "endTurn"
|
|
140
|
+
} : {
|
|
141
|
+
app_session_uuid: "aps_mock",
|
|
142
|
+
expires_in: 600,
|
|
143
|
+
submode: "auto",
|
|
144
|
+
granted_tools: []
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
if (entry.events && entry.events.length > 0) {
|
|
148
|
+
const sid = `mock-${++this.streamCounter}`;
|
|
149
|
+
(async () => {
|
|
150
|
+
let seq = 0;
|
|
151
|
+
for (const ev of entry.events ?? []) {
|
|
152
|
+
if (ev.delay_ms) await setTimeout$1(ev.delay_ms);
|
|
153
|
+
seq += 1;
|
|
154
|
+
args.onEvent("rpc.stream", {
|
|
155
|
+
stream_id: sid,
|
|
156
|
+
seq,
|
|
157
|
+
payload: ev.payload,
|
|
158
|
+
done: false
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
args.onEvent("rpc.stream", {
|
|
162
|
+
stream_id: sid,
|
|
163
|
+
seq: seq + 1,
|
|
164
|
+
payload: { event: "end" },
|
|
165
|
+
done: true
|
|
166
|
+
});
|
|
167
|
+
})();
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
result: {
|
|
171
|
+
stream_id: sid,
|
|
172
|
+
run_id: "mock-run"
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
result: entry.result ?? {}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async dispatchReal(args) {
|
|
182
|
+
const acc = this.account();
|
|
183
|
+
if (args.ns === "llm" && args.method === "complete") {
|
|
184
|
+
const ms = await this.mintComplete(args.windowUuid);
|
|
185
|
+
const result = await this.postJson(`${canonicalHost(acc.host)}/api/v1/copilot/app/complete`, ms.appSessionToken, args.args);
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
result
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (args.ns === "agent") switch (args.method) {
|
|
192
|
+
case "session.create": {
|
|
193
|
+
const ms = await this.mintAgent(args.args);
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
result: {
|
|
197
|
+
app_session_uuid: ms.appSessionUuid,
|
|
198
|
+
expires_in: Math.max(0, ms.expiresAt - Math.floor(Date.now() / 1e3)),
|
|
199
|
+
submode: ms.submode,
|
|
200
|
+
fixed_client_id: args.args.fixed_client_id ?? null,
|
|
201
|
+
granted_tools: []
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
case "session.run": {
|
|
206
|
+
const apsUuid = String(args.args.app_session_uuid ?? "");
|
|
207
|
+
const ms = this.mintedAgent.get(apsUuid);
|
|
208
|
+
if (!ms) return {
|
|
209
|
+
ok: false,
|
|
210
|
+
error: {
|
|
211
|
+
code: "session_expired",
|
|
212
|
+
message: "no cached session"
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const sid = `dev-${++this.streamCounter}`;
|
|
216
|
+
this.pumpAgentRun({
|
|
217
|
+
host: acc.host,
|
|
218
|
+
token: ms.appSessionToken,
|
|
219
|
+
body: {
|
|
220
|
+
...args.args,
|
|
221
|
+
stream: true
|
|
222
|
+
},
|
|
223
|
+
streamId: sid,
|
|
224
|
+
windowUuid: args.windowUuid,
|
|
225
|
+
onEvent: args.onEvent
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
result: {
|
|
230
|
+
stream_id: sid,
|
|
231
|
+
run_id: String(args.args.run_id ?? sid)
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
case "session.cancel": {
|
|
236
|
+
const apsUuid = String(args.args.app_session_uuid ?? "");
|
|
237
|
+
const ms = this.mintedAgent.get(apsUuid);
|
|
238
|
+
if (!ms) return {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: {
|
|
241
|
+
code: "session_expired",
|
|
242
|
+
message: "no cached session"
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const out = await this.postJson(`${canonicalHost(acc.host)}/api/v1/copilot/app/agent/cancel`, ms.appSessionToken, args.args);
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
result: out
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
case "session.history": return {
|
|
252
|
+
ok: false,
|
|
253
|
+
error: {
|
|
254
|
+
code: "not_supported",
|
|
255
|
+
message: "agent.session.history is not exposed over HTTP; available only via in-process store"
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
case "session.delete": {
|
|
259
|
+
const apsUuid = String(args.args.app_session_uuid ?? "");
|
|
260
|
+
const ms = this.mintedAgent.get(apsUuid);
|
|
261
|
+
this.mintedAgent.delete(apsUuid);
|
|
262
|
+
if (!ms) return {
|
|
263
|
+
ok: true,
|
|
264
|
+
result: { deleted: true }
|
|
265
|
+
};
|
|
266
|
+
const url = `${canonicalHost(acc.host)}/api/v1/copilot/app/sessions/${encodeURIComponent(apsUuid)}`;
|
|
267
|
+
const res = await fetch(url, {
|
|
268
|
+
method: "DELETE",
|
|
269
|
+
headers: { authorization: `Bearer ${ms.appSessionToken}` }
|
|
270
|
+
});
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const text = await res.text().catch(() => "");
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: {
|
|
276
|
+
code: "transport",
|
|
277
|
+
message: `HTTP ${res.status}: ${text}`
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
result: { deleted: true }
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: {
|
|
290
|
+
code: "unknown_method",
|
|
291
|
+
message: `${args.ns}.${args.method}`
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async postJson(url, token, body) {
|
|
296
|
+
const res = await fetch(url, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: {
|
|
299
|
+
"content-type": "application/json",
|
|
300
|
+
authorization: `Bearer ${token}`
|
|
301
|
+
},
|
|
302
|
+
body: JSON.stringify(body)
|
|
303
|
+
});
|
|
304
|
+
if (!res.ok) {
|
|
305
|
+
const text = await res.text().catch(() => "");
|
|
306
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
307
|
+
}
|
|
308
|
+
return res.json();
|
|
309
|
+
}
|
|
310
|
+
/** Consume an SSE response and forward each frame as `rpc.stream`. */
|
|
311
|
+
async pumpAgentRun(args) {
|
|
312
|
+
const url = `${canonicalHost(args.host)}/api/v1/copilot/app/agent`;
|
|
313
|
+
let seq = 0;
|
|
314
|
+
const emit = (payload, done) => {
|
|
315
|
+
seq += 1;
|
|
316
|
+
args.onEvent("rpc.stream", {
|
|
317
|
+
stream_id: args.streamId,
|
|
318
|
+
window_uuid: args.windowUuid,
|
|
319
|
+
seq,
|
|
320
|
+
payload,
|
|
321
|
+
done
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(url, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: {
|
|
328
|
+
"content-type": "application/json",
|
|
329
|
+
authorization: `Bearer ${args.token}`,
|
|
330
|
+
accept: "text/event-stream"
|
|
331
|
+
},
|
|
332
|
+
body: JSON.stringify(args.body)
|
|
333
|
+
});
|
|
334
|
+
if (!res.ok || !res.body) {
|
|
335
|
+
const text = await res.text().catch(() => "");
|
|
336
|
+
emit({
|
|
337
|
+
event: "error",
|
|
338
|
+
code: "http",
|
|
339
|
+
message: `HTTP ${res.status}: ${text}`
|
|
340
|
+
}, true);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const reader = res.body.getReader();
|
|
344
|
+
const decoder = new TextDecoder();
|
|
345
|
+
let buf = "";
|
|
346
|
+
while (true) {
|
|
347
|
+
const { value, done } = await reader.read();
|
|
348
|
+
if (done) break;
|
|
349
|
+
buf += decoder.decode(value, { stream: true });
|
|
350
|
+
let idx;
|
|
351
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
352
|
+
const frame = buf.slice(0, idx);
|
|
353
|
+
buf = buf.slice(idx + 2);
|
|
354
|
+
const payload = parseSseFrame(frame);
|
|
355
|
+
if (payload != null) emit(payload, false);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (buf.trim().length > 0) {
|
|
359
|
+
const payload = parseSseFrame(buf);
|
|
360
|
+
if (payload != null) emit(payload, false);
|
|
361
|
+
}
|
|
362
|
+
emit({ event: "end" }, true);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
emit({
|
|
365
|
+
event: "error",
|
|
366
|
+
code: "transport",
|
|
367
|
+
message: e.message
|
|
368
|
+
}, true);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
function parseSseFrame(frame) {
|
|
373
|
+
const lines = frame.split(/\r?\n/);
|
|
374
|
+
let dataLines = [];
|
|
375
|
+
let eventName = null;
|
|
376
|
+
for (const raw of lines) {
|
|
377
|
+
if (!raw || raw.startsWith(":")) continue;
|
|
378
|
+
if (raw.startsWith("data:")) dataLines.push(raw.slice(5).trimStart());
|
|
379
|
+
else if (raw.startsWith("event:")) eventName = raw.slice(6).trim();
|
|
380
|
+
}
|
|
381
|
+
if (dataLines.length === 0) return null;
|
|
382
|
+
const data = dataLines.join("\n");
|
|
383
|
+
try {
|
|
384
|
+
const obj = JSON.parse(data);
|
|
385
|
+
if (eventName && obj && typeof obj === "object" && !("event" in obj)) return {
|
|
386
|
+
...obj,
|
|
387
|
+
event: eventName
|
|
388
|
+
};
|
|
389
|
+
return obj;
|
|
390
|
+
} catch {
|
|
391
|
+
return {
|
|
392
|
+
event: eventName ?? "raw",
|
|
393
|
+
text: data
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/harness/server.ts
|
|
400
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
401
|
+
const __dirname = dirname(__filename);
|
|
402
|
+
const MIME = {
|
|
403
|
+
".html": "text/html; charset=utf-8",
|
|
404
|
+
".js": "application/javascript; charset=utf-8",
|
|
405
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
406
|
+
".css": "text/css; charset=utf-8",
|
|
407
|
+
".json": "application/json; charset=utf-8",
|
|
408
|
+
".svg": "image/svg+xml",
|
|
409
|
+
".png": "image/png",
|
|
410
|
+
".jpg": "image/jpeg",
|
|
411
|
+
".jpeg": "image/jpeg",
|
|
412
|
+
".gif": "image/gif",
|
|
413
|
+
".woff": "font/woff",
|
|
414
|
+
".woff2": "font/woff2",
|
|
415
|
+
".map": "application/json"
|
|
416
|
+
};
|
|
417
|
+
var HarnessServer = class {
|
|
418
|
+
server = createServer((req, res) => this.handle(req, res));
|
|
419
|
+
wss = null;
|
|
420
|
+
sessionId = null;
|
|
421
|
+
liveSockets = new Set();
|
|
422
|
+
watchers = [];
|
|
423
|
+
reloadDebounce = null;
|
|
424
|
+
/** Pending events queued by the LLM bridge — drained alongside Python events. */
|
|
425
|
+
llmEventQueue = [];
|
|
426
|
+
llmBridge;
|
|
427
|
+
constructor(cfg, bridge) {
|
|
428
|
+
this.cfg = cfg;
|
|
429
|
+
this.bridge = bridge;
|
|
430
|
+
this.llmBridge = cfg.llm ? new LlmBridge(cfg.llm) : null;
|
|
431
|
+
}
|
|
432
|
+
async listen() {
|
|
433
|
+
if (this.cfg.executas && this.cfg.executas.length > 0) await this.bridge.call("executas.register", { executas: this.cfg.executas.map((e) => ({
|
|
434
|
+
tool_id: e.tool_id,
|
|
435
|
+
project_dir: e.project_dir,
|
|
436
|
+
command: e.command ?? null
|
|
437
|
+
})) });
|
|
438
|
+
await new Promise((res, rej) => this.server.listen(this.cfg.port, () => res()).once("error", rej));
|
|
439
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
440
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
441
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
442
|
+
if (url.pathname !== "/ws") {
|
|
443
|
+
socket.destroy();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
447
|
+
const sid = url.searchParams.get("session_id");
|
|
448
|
+
if (!sid || sid !== this.sessionId) {
|
|
449
|
+
ws.close(1008, "unknown session_id");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.liveSockets.add(ws);
|
|
453
|
+
const timer = setInterval(async () => {
|
|
454
|
+
try {
|
|
455
|
+
const out = await this.bridge.call("session.drain_events", { session_id: sid });
|
|
456
|
+
for (const ev of out.events) ws.send(JSON.stringify({
|
|
457
|
+
kind: "event",
|
|
458
|
+
...ev
|
|
459
|
+
}));
|
|
460
|
+
if (this.llmEventQueue.length > 0) {
|
|
461
|
+
const drained = this.llmEventQueue.splice(0);
|
|
462
|
+
for (const ev of drained) ws.send(JSON.stringify({
|
|
463
|
+
kind: "event",
|
|
464
|
+
...ev
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
} catch (e) {
|
|
468
|
+
ws.close(1011, `drain failed: ${e.message}`);
|
|
469
|
+
clearInterval(timer);
|
|
470
|
+
}
|
|
471
|
+
}, 200);
|
|
472
|
+
ws.on("close", () => {
|
|
473
|
+
this.liveSockets.delete(ws);
|
|
474
|
+
clearInterval(timer);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
if (this.cfg.watch) this.startWatcher();
|
|
479
|
+
}
|
|
480
|
+
async close() {
|
|
481
|
+
for (const w of this.watchers) w.close();
|
|
482
|
+
this.watchers = [];
|
|
483
|
+
if (this.reloadDebounce) clearTimeout(this.reloadDebounce);
|
|
484
|
+
if (this.wss) {
|
|
485
|
+
for (const c of this.wss.clients) c.terminate();
|
|
486
|
+
this.wss.close();
|
|
487
|
+
}
|
|
488
|
+
await new Promise((res) => this.server.close(() => res()));
|
|
489
|
+
}
|
|
490
|
+
startWatcher() {
|
|
491
|
+
const broadcastReload = (path) => {
|
|
492
|
+
if (this.reloadDebounce) clearTimeout(this.reloadDebounce);
|
|
493
|
+
this.reloadDebounce = setTimeout(() => {
|
|
494
|
+
const env = JSON.stringify({
|
|
495
|
+
kind: "reload",
|
|
496
|
+
path
|
|
497
|
+
});
|
|
498
|
+
for (const ws of this.liveSockets) if (ws.readyState === ws.OPEN) ws.send(env);
|
|
499
|
+
}, 100);
|
|
500
|
+
};
|
|
501
|
+
try {
|
|
502
|
+
this.watchers.push(watch(this.cfg.bundleDir, { recursive: true }, (_evt, filename) => {
|
|
503
|
+
if (filename) broadcastReload(`bundle/${filename}`);
|
|
504
|
+
}));
|
|
505
|
+
} catch (e) {
|
|
506
|
+
process.stderr.write(`[harness] watcher failed to attach to ${this.cfg.bundleDir}: ${e.message}\n`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async handle(req, res) {
|
|
510
|
+
try {
|
|
511
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
512
|
+
const method = req.method ?? "GET";
|
|
513
|
+
if (method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) return await this.serveDashboard(res);
|
|
514
|
+
if (method === "GET" && url.pathname === "/api/config") return this.json(res, 200, {
|
|
515
|
+
app_slug: this.cfg.slug,
|
|
516
|
+
view: this.cfg.view ?? null,
|
|
517
|
+
bundle_base: `/anna-apps/${this.cfg.slug}/dev/${this.cfg.bundleEntry}`,
|
|
518
|
+
executas: (this.cfg.executas ?? []).map((e) => e.tool_id),
|
|
519
|
+
watch: !!this.cfg.watch
|
|
520
|
+
});
|
|
521
|
+
if (method === "POST" && url.pathname === "/api/session/create") return await this.createSession(res);
|
|
522
|
+
if (method === "POST" && url.pathname === "/api/session/call") return await this.proxyCall(req, res);
|
|
523
|
+
if (method === "POST" && url.pathname === "/api/session/refresh-token") return await this.refreshToken(res);
|
|
524
|
+
if (method === "GET" && url.pathname.startsWith("/static/anna-apps/_sdk/")) return await this.serveSdk(url.pathname, res);
|
|
525
|
+
if (method === "GET" && url.pathname.startsWith(`/anna-apps/${this.cfg.slug}/dev/`)) {
|
|
526
|
+
const rel = url.pathname.replace(`/anna-apps/${this.cfg.slug}/dev/`, "");
|
|
527
|
+
return await this.serveBundleAsset(rel, res);
|
|
528
|
+
}
|
|
529
|
+
this.text(res, 404, `not found: ${url.pathname}`);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
this.text(res, 500, `harness error: ${e.message}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async serveDashboard(res) {
|
|
535
|
+
const file = join(__dirname, "dashboard.html");
|
|
536
|
+
const html = await readFile(file, "utf-8");
|
|
537
|
+
res.writeHead(200, {
|
|
538
|
+
"content-type": MIME[".html"],
|
|
539
|
+
"cache-control": "no-store"
|
|
540
|
+
});
|
|
541
|
+
res.end(html);
|
|
542
|
+
}
|
|
543
|
+
async createSession(res) {
|
|
544
|
+
if (this.sessionId) {
|
|
545
|
+
try {
|
|
546
|
+
await this.bridge.call("session.close", { session_id: this.sessionId });
|
|
547
|
+
} catch {}
|
|
548
|
+
this.sessionId = null;
|
|
549
|
+
}
|
|
550
|
+
const out = await this.bridge.call("session.create", {
|
|
551
|
+
user_id: this.cfg.userId,
|
|
552
|
+
manifest: this.cfg.manifest,
|
|
553
|
+
view: this.cfg.view,
|
|
554
|
+
entry_payload: this.cfg.entryPayload ?? {},
|
|
555
|
+
app_slug: this.cfg.slug
|
|
556
|
+
});
|
|
557
|
+
this.sessionId = out.session_id;
|
|
558
|
+
this.json(res, 200, out);
|
|
559
|
+
}
|
|
560
|
+
async proxyCall(req, res) {
|
|
561
|
+
const body = await readBody(req);
|
|
562
|
+
let parsed;
|
|
563
|
+
try {
|
|
564
|
+
parsed = JSON.parse(body);
|
|
565
|
+
} catch {
|
|
566
|
+
return this.json(res, 400, {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: {
|
|
569
|
+
code: "bad_request",
|
|
570
|
+
message: "invalid json body"
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if (this.llmBridge != null && LlmBridge.handles(parsed.ns, parsed.method)) {
|
|
575
|
+
const out = await this.llmBridge.dispatch({
|
|
576
|
+
windowUuid: this.sessionId ?? "harness",
|
|
577
|
+
ns: parsed.ns,
|
|
578
|
+
method: parsed.method,
|
|
579
|
+
args: parsed.args ?? {},
|
|
580
|
+
onEvent: (kind, payload) => {
|
|
581
|
+
this.llmEventQueue.push({
|
|
582
|
+
event: kind,
|
|
583
|
+
payload
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
this.json(res, 200, out);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
const out = await this.bridge.call("session.call", {
|
|
592
|
+
session_id: parsed.session_id,
|
|
593
|
+
ns: parsed.ns,
|
|
594
|
+
method: parsed.method,
|
|
595
|
+
args: parsed.args ?? {}
|
|
596
|
+
});
|
|
597
|
+
this.json(res, 200, out);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
this.json(res, 200, {
|
|
600
|
+
ok: false,
|
|
601
|
+
error: {
|
|
602
|
+
code: "transport",
|
|
603
|
+
message: e.message
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async refreshToken(res) {
|
|
609
|
+
if (!this.sessionId) return this.json(res, 400, { error: "no active session" });
|
|
610
|
+
try {
|
|
611
|
+
const out = await this.bridge.call("session.refresh_token", { session_id: this.sessionId });
|
|
612
|
+
this.json(res, 200, out);
|
|
613
|
+
} catch (e) {
|
|
614
|
+
this.json(res, 500, { error: e.message });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async serveSdk(pathname, res) {
|
|
618
|
+
const sdkRel = pathname.replace(/^\/static\/anna-apps\/_sdk\/[^/]+\//, "");
|
|
619
|
+
let distRoot;
|
|
620
|
+
try {
|
|
621
|
+
const req = createRequire(import.meta.url);
|
|
622
|
+
distRoot = dirname(req.resolve("@anna-ai/app-runtime"));
|
|
623
|
+
} catch (e) {
|
|
624
|
+
return this.text(res, 500, `@anna-ai/app-runtime is not installed: ${e.message}`);
|
|
625
|
+
}
|
|
626
|
+
const abs = resolve(distRoot, sdkRel);
|
|
627
|
+
if (!abs.startsWith(distRoot)) return this.text(res, 403, "forbidden");
|
|
628
|
+
return this.serveFile(abs, res);
|
|
629
|
+
}
|
|
630
|
+
async serveBundleAsset(rel, res) {
|
|
631
|
+
const abs = resolve(this.cfg.bundleDir, normalize(rel));
|
|
632
|
+
if (!abs.startsWith(resolve(this.cfg.bundleDir))) return this.text(res, 403, "forbidden");
|
|
633
|
+
return this.serveFile(abs, res);
|
|
634
|
+
}
|
|
635
|
+
async serveFile(abs, res) {
|
|
636
|
+
let stat;
|
|
637
|
+
try {
|
|
638
|
+
stat = statSync(abs);
|
|
639
|
+
} catch {
|
|
640
|
+
return this.text(res, 404, `not found: ${abs}`);
|
|
641
|
+
}
|
|
642
|
+
if (!stat.isFile()) return this.text(res, 404, "not a file");
|
|
643
|
+
const ext = abs.slice(abs.lastIndexOf("."));
|
|
644
|
+
res.writeHead(200, {
|
|
645
|
+
"content-type": MIME[ext] ?? "application/octet-stream",
|
|
646
|
+
"cache-control": "no-store",
|
|
647
|
+
"content-length": String(stat.size)
|
|
648
|
+
});
|
|
649
|
+
createReadStream(abs).pipe(res);
|
|
650
|
+
}
|
|
651
|
+
json(res, status, body) {
|
|
652
|
+
const text = JSON.stringify(body);
|
|
653
|
+
res.writeHead(status, {
|
|
654
|
+
"content-type": MIME[".json"],
|
|
655
|
+
"content-length": String(Buffer.byteLength(text)),
|
|
656
|
+
"cache-control": "no-store"
|
|
657
|
+
});
|
|
658
|
+
res.end(text);
|
|
659
|
+
}
|
|
660
|
+
text(res, status, body) {
|
|
661
|
+
res.writeHead(status, {
|
|
662
|
+
"content-type": "text/plain; charset=utf-8",
|
|
663
|
+
"content-length": String(Buffer.byteLength(body))
|
|
664
|
+
});
|
|
665
|
+
res.end(body);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
function readBody(req) {
|
|
669
|
+
return new Promise((resolve$1, reject) => {
|
|
670
|
+
const chunks = [];
|
|
671
|
+
req.on("data", (c) => chunks.push(c));
|
|
672
|
+
req.on("end", () => resolve$1(Buffer.concat(chunks).toString("utf-8")));
|
|
673
|
+
req.on("error", reject);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
//#endregion
|
|
678
|
+
export { HarnessServer };
|