agentweaver 0.1.17 → 0.1.19
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 +112 -23
- package/dist/artifacts.js +41 -0
- package/dist/index.js +258 -29
- package/dist/interactive/controller.js +323 -13
- package/dist/interactive/ink/index.js +2 -2
- package/dist/interactive/state.js +10 -0
- package/dist/interactive/web/index.js +326 -0
- package/dist/interactive/web/protocol.js +160 -0
- package/dist/interactive/web/server.js +1011 -0
- package/dist/interactive/web/static/app.js +1580 -0
- package/dist/interactive/web/static/index.html +114 -0
- package/dist/interactive/web/static/styles.css +2 -0
- package/dist/interactive/web/static/styles.input.css +849 -0
- package/dist/pipeline/flow-catalog.js +4 -0
- package/dist/pipeline/flow-specs/auto-common-guided.json +313 -0
- package/dist/pipeline/flow-specs/auto-common.json +3 -1
- package/dist/pipeline/flow-specs/design-review/design-review-loop.json +2 -0
- package/dist/pipeline/flow-specs/design-review.json +2 -0
- package/dist/pipeline/flow-specs/implement.json +3 -1
- package/dist/pipeline/flow-specs/plan.json +4 -0
- package/dist/pipeline/flow-specs/playbook-init.json +199 -0
- package/dist/pipeline/flow-specs/review/review-fix.json +3 -1
- package/dist/pipeline/flow-specs/review/review-loop.json +4 -0
- package/dist/pipeline/flow-specs/review/review.json +2 -0
- package/dist/pipeline/node-registry.js +45 -0
- package/dist/pipeline/nodes/flow-run-node.js +13 -1
- package/dist/pipeline/nodes/playbook-ensure-node.js +115 -0
- package/dist/pipeline/nodes/playbook-inventory-node.js +51 -0
- package/dist/pipeline/nodes/playbook-questions-form-node.js +166 -0
- package/dist/pipeline/nodes/playbook-write-node.js +243 -0
- package/dist/pipeline/nodes/project-guidance-node.js +69 -0
- package/dist/pipeline/prompt-registry.js +4 -1
- package/dist/pipeline/prompt-runtime.js +6 -2
- package/dist/pipeline/spec-types.js +19 -0
- package/dist/pipeline/value-resolver.js +39 -1
- package/dist/playbook/practice-candidates.js +12 -0
- package/dist/playbook/repo-inventory.js +208 -0
- package/dist/prompts.js +31 -0
- package/dist/runtime/artifact-catalog.js +379 -0
- package/dist/runtime/playbook.js +485 -0
- package/dist/runtime/project-guidance.js +339 -0
- package/dist/structured-artifact-schema-registry.js +8 -0
- package/dist/structured-artifact-schemas.json +235 -0
- package/dist/structured-artifacts.js +7 -1
- package/docs/declarative-workflows.md +565 -0
- package/docs/features.md +77 -0
- package/docs/playbook.md +327 -0
- package/package.json +8 -3
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArtifactReference } from "../../artifact-manifest.js";
|
|
9
|
+
import { scopeArtifactsDir, scopeWorkspaceDir } from "../../artifacts.js";
|
|
10
|
+
import { groupArtifactCatalog, inferArtifactRenderKind, inferArtifactRole, inferArtifactTitle, } from "../../runtime/artifact-catalog.js";
|
|
11
|
+
import { createArtifactRegistry } from "../../runtime/artifact-registry.js";
|
|
12
|
+
import { parseClientAction } from "./protocol.js";
|
|
13
|
+
const STATIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
|
|
14
|
+
const MAX_PREVIEW_SIZE = 512 * 1024;
|
|
15
|
+
const MAX_JSON_PARSE_SIZE = 2 * 1024 * 1024;
|
|
16
|
+
const CONTENT_TYPES = new Map([
|
|
17
|
+
[".html", "text/html; charset=utf-8"],
|
|
18
|
+
[".css", "text/css; charset=utf-8"],
|
|
19
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
20
|
+
[".json", "application/json; charset=utf-8"],
|
|
21
|
+
[".svg", "image/svg+xml; charset=utf-8"],
|
|
22
|
+
]);
|
|
23
|
+
const BASIC_AUTH_REALM = "AgentWeaver Web UI";
|
|
24
|
+
const ARTIFACT_API_PREFIX = "/__agentweaver/api/artifacts";
|
|
25
|
+
const ARTIFACT_ERROR_STATUSES = {
|
|
26
|
+
invalid_id: 400,
|
|
27
|
+
missing_scope: 400,
|
|
28
|
+
scope_mismatch: 403,
|
|
29
|
+
forbidden_path: 403,
|
|
30
|
+
not_found: 404,
|
|
31
|
+
unsupported_preview: 415,
|
|
32
|
+
read_failed: 500,
|
|
33
|
+
};
|
|
34
|
+
function hashCredential(value) {
|
|
35
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
36
|
+
}
|
|
37
|
+
function timingSafeStringEqual(actual, expected) {
|
|
38
|
+
return timingSafeEqual(hashCredential(actual), hashCredential(expected));
|
|
39
|
+
}
|
|
40
|
+
function parseBasicAuthorization(header) {
|
|
41
|
+
if (!header) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const match = header.match(/^Basic\s+(.+)$/i);
|
|
45
|
+
if (!match?.[1]) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let decoded;
|
|
49
|
+
try {
|
|
50
|
+
decoded = Buffer.from(match[1], "base64").toString("utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const separatorIndex = decoded.indexOf(":");
|
|
56
|
+
if (separatorIndex < 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
username: decoded.slice(0, separatorIndex),
|
|
61
|
+
password: decoded.slice(separatorIndex + 1),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function isAuthorized(request, auth) {
|
|
65
|
+
if (!auth) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const credentials = parseBasicAuthorization(request.headers.authorization);
|
|
69
|
+
if (!credentials) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return timingSafeStringEqual(credentials.username, auth.username) && timingSafeStringEqual(credentials.password, auth.password);
|
|
73
|
+
}
|
|
74
|
+
function writeAuthRequired(response) {
|
|
75
|
+
response.writeHead(401, {
|
|
76
|
+
"content-type": "text/plain; charset=utf-8",
|
|
77
|
+
"www-authenticate": `Basic realm="${BASIC_AUTH_REALM}"`,
|
|
78
|
+
"cache-control": "no-store",
|
|
79
|
+
});
|
|
80
|
+
response.end("Authentication required");
|
|
81
|
+
}
|
|
82
|
+
function rejectUnauthorizedUpgrade(socket) {
|
|
83
|
+
socket.write([
|
|
84
|
+
"HTTP/1.1 401 Unauthorized",
|
|
85
|
+
`WWW-Authenticate: Basic realm="${BASIC_AUTH_REALM}"`,
|
|
86
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
87
|
+
"Connection: close",
|
|
88
|
+
"",
|
|
89
|
+
"Authentication required",
|
|
90
|
+
].join("\r\n"));
|
|
91
|
+
socket.destroy();
|
|
92
|
+
}
|
|
93
|
+
function staticAssetPath(requestUrl) {
|
|
94
|
+
const parsed = new URL(requestUrl ?? "/", "http://agentweaver.local");
|
|
95
|
+
const pathname = parsed.pathname === "/" ? "/index.html" : parsed.pathname;
|
|
96
|
+
if (pathname !== "/index.html" && !pathname.startsWith("/static/")) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const relativePath = pathname === "/index.html" ? "index.html" : pathname.slice("/static/".length);
|
|
100
|
+
const normalized = path.normalize(relativePath);
|
|
101
|
+
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const assetPath = path.join(STATIC_DIR, normalized);
|
|
105
|
+
if (!assetPath.startsWith(STATIC_DIR) || !existsSync(assetPath) || !statSync(assetPath).isFile()) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return assetPath;
|
|
109
|
+
}
|
|
110
|
+
function serveStaticAsset(request, response) {
|
|
111
|
+
if (request.method !== "GET") {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const assetPath = staticAssetPath(request.url);
|
|
115
|
+
if (!assetPath) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
response.writeHead(200, {
|
|
119
|
+
"content-type": CONTENT_TYPES.get(path.extname(assetPath)) ?? "application/octet-stream",
|
|
120
|
+
"cache-control": "no-store",
|
|
121
|
+
});
|
|
122
|
+
response.end(readFileSync(assetPath));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
function writeJson(response, statusCode, value) {
|
|
126
|
+
response.writeHead(statusCode, {
|
|
127
|
+
"content-type": "application/json; charset=utf-8",
|
|
128
|
+
"cache-control": "no-store",
|
|
129
|
+
});
|
|
130
|
+
response.end(JSON.stringify(value));
|
|
131
|
+
}
|
|
132
|
+
function writeArtifactApiError(response, code, message, artifact) {
|
|
133
|
+
writeJson(response, ARTIFACT_ERROR_STATUSES[code], {
|
|
134
|
+
code,
|
|
135
|
+
message,
|
|
136
|
+
...(artifact ? { artifact } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function safeArtifactMetadata(item) {
|
|
140
|
+
return {
|
|
141
|
+
id: item.id,
|
|
142
|
+
scopeKey: item.scopeKey,
|
|
143
|
+
runId: item.runId,
|
|
144
|
+
logicalKey: item.logicalKey,
|
|
145
|
+
title: item.title,
|
|
146
|
+
relativePath: item.relativePath,
|
|
147
|
+
kind: item.kind,
|
|
148
|
+
role: item.role,
|
|
149
|
+
phaseId: item.phaseId,
|
|
150
|
+
stepId: item.stepId,
|
|
151
|
+
schemaId: item.schemaId,
|
|
152
|
+
sizeBytes: item.sizeBytes,
|
|
153
|
+
updatedAt: item.updatedAt,
|
|
154
|
+
isLatest: item.isLatest,
|
|
155
|
+
source: item.source,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function normalizePathSeparators(value) {
|
|
159
|
+
return value.replace(/\\/g, "/");
|
|
160
|
+
}
|
|
161
|
+
function isInsideDirectory(parent, candidate) {
|
|
162
|
+
const relative = path.relative(parent, candidate);
|
|
163
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
164
|
+
}
|
|
165
|
+
function safeDecodeURIComponent(value) {
|
|
166
|
+
try {
|
|
167
|
+
return decodeURIComponent(value);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function parseArtifactApiRoute(requestUrl) {
|
|
174
|
+
const parsed = new URL(requestUrl ?? "/", "http://agentweaver.local");
|
|
175
|
+
const scopeKey = parsed.searchParams.get("scope");
|
|
176
|
+
const runIds = parsed.searchParams.getAll("runId").filter((value, index, values) => (value.length > 0 && values.indexOf(value) === index));
|
|
177
|
+
if (parsed.pathname === ARTIFACT_API_PREFIX) {
|
|
178
|
+
return { kind: "list", scopeKey, runIds };
|
|
179
|
+
}
|
|
180
|
+
if (!parsed.pathname.startsWith(`${ARTIFACT_API_PREFIX}/`)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const suffix = parsed.pathname.slice(`${ARTIFACT_API_PREFIX}/`.length);
|
|
184
|
+
for (const action of ["preview", "raw", "download"]) {
|
|
185
|
+
const actionSuffix = `/${action}`;
|
|
186
|
+
if (!suffix.endsWith(actionSuffix)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const encodedId = suffix.slice(0, -actionSuffix.length);
|
|
190
|
+
const artifactId = safeDecodeURIComponent(encodedId);
|
|
191
|
+
if (!artifactId || artifactId.includes("\0")) {
|
|
192
|
+
return { kind: "content", artifactId: "", action, scopeKey, runIds };
|
|
193
|
+
}
|
|
194
|
+
return { kind: "content", artifactId, action, scopeKey, runIds };
|
|
195
|
+
}
|
|
196
|
+
return { kind: "content", artifactId: "", action: "preview", scopeKey, runIds };
|
|
197
|
+
}
|
|
198
|
+
function filterCatalog(catalog, scopeKey, runIds) {
|
|
199
|
+
const runIdSet = new Set(runIds);
|
|
200
|
+
let items = catalog.items.filter((item) => item.scopeKey === scopeKey && item.kind === "markdown");
|
|
201
|
+
if (runIdSet.size > 0) {
|
|
202
|
+
items = items.filter((item) => item.runId !== null && runIdSet.has(item.runId));
|
|
203
|
+
}
|
|
204
|
+
const sortedItems = items.slice();
|
|
205
|
+
return {
|
|
206
|
+
scopeKey,
|
|
207
|
+
items: sortedItems,
|
|
208
|
+
groups: groupArtifactCatalog(sortedItems),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async function loadArtifactCatalog(options, input) {
|
|
212
|
+
if (!options.getArtifactCatalog) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return options.getArtifactCatalog(input);
|
|
216
|
+
}
|
|
217
|
+
function scopeRelativePath(scopeKey, payloadPath) {
|
|
218
|
+
const workspaceDir = path.resolve(scopeWorkspaceDir(scopeKey));
|
|
219
|
+
const normalizedPayloadPath = path.resolve(payloadPath);
|
|
220
|
+
if (isInsideDirectory(workspaceDir, normalizedPayloadPath)) {
|
|
221
|
+
return normalizePathSeparators(path.relative(workspaceDir, normalizedPayloadPath));
|
|
222
|
+
}
|
|
223
|
+
return normalizePathSeparators(path.basename(normalizedPayloadPath));
|
|
224
|
+
}
|
|
225
|
+
function itemFromManifest(scopeKey, manifest) {
|
|
226
|
+
const relativePath = scopeRelativePath(scopeKey, manifest.payload_path);
|
|
227
|
+
const kind = inferArtifactRenderKind({
|
|
228
|
+
payloadFamily: manifest.payload_family,
|
|
229
|
+
schemaId: manifest.schema_id,
|
|
230
|
+
filePath: manifest.payload_path,
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
id: manifest.artifact_id,
|
|
234
|
+
scopeKey,
|
|
235
|
+
runId: manifest.run_id,
|
|
236
|
+
logicalKey: manifest.logical_key,
|
|
237
|
+
title: inferArtifactTitle(scopeKey, manifest.logical_key, relativePath),
|
|
238
|
+
relativePath,
|
|
239
|
+
kind,
|
|
240
|
+
role: inferArtifactRole(manifest.logical_key, relativePath),
|
|
241
|
+
phaseId: manifest.phase_id || null,
|
|
242
|
+
stepId: manifest.step_id || null,
|
|
243
|
+
schemaId: manifest.schema_id || null,
|
|
244
|
+
sizeBytes: 0,
|
|
245
|
+
updatedAt: manifest.created_at,
|
|
246
|
+
isLatest: manifest.status === "ready",
|
|
247
|
+
source: "manifest",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function realExistingRoots(scopeKey) {
|
|
251
|
+
const roots = [scopeWorkspaceDir(scopeKey), scopeArtifactsDir(scopeKey)];
|
|
252
|
+
const realRoots = [];
|
|
253
|
+
for (const root of roots) {
|
|
254
|
+
try {
|
|
255
|
+
realRoots.push(realpathSync(root));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// A missing .artifacts directory should not block regular scope files.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return [...new Set(realRoots)];
|
|
262
|
+
}
|
|
263
|
+
function assertContainedRegularFile(scopeKey, filePath) {
|
|
264
|
+
let realPath;
|
|
265
|
+
try {
|
|
266
|
+
realPath = realpathSync(filePath);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
throw new ArtifactApiResolutionError("not_found");
|
|
270
|
+
}
|
|
271
|
+
const allowedRoots = realExistingRoots(scopeKey);
|
|
272
|
+
if (allowedRoots.length === 0 || !allowedRoots.some((root) => isInsideDirectory(root, realPath))) {
|
|
273
|
+
throw new ArtifactApiResolutionError("forbidden_path");
|
|
274
|
+
}
|
|
275
|
+
let stats;
|
|
276
|
+
try {
|
|
277
|
+
stats = statSync(realPath);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new ArtifactApiResolutionError("not_found");
|
|
281
|
+
}
|
|
282
|
+
if (!stats.isFile()) {
|
|
283
|
+
throw new ArtifactApiResolutionError("forbidden_path");
|
|
284
|
+
}
|
|
285
|
+
return { filePath, realPath, stats };
|
|
286
|
+
}
|
|
287
|
+
class ArtifactApiResolutionError extends Error {
|
|
288
|
+
code;
|
|
289
|
+
constructor(code) {
|
|
290
|
+
super(code);
|
|
291
|
+
this.code = code;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function catalogFilePath(scopeKey, item) {
|
|
295
|
+
if (path.isAbsolute(item.relativePath) || item.relativePath.includes("\0")) {
|
|
296
|
+
throw new ArtifactApiResolutionError("forbidden_path");
|
|
297
|
+
}
|
|
298
|
+
return path.join(scopeWorkspaceDir(scopeKey), item.relativePath);
|
|
299
|
+
}
|
|
300
|
+
function findCatalogItemForReference(catalog, reference) {
|
|
301
|
+
return catalog.items.find((item) => item.id === reference) ?? null;
|
|
302
|
+
}
|
|
303
|
+
function resolveArtifactRequest(catalog, artifactId) {
|
|
304
|
+
if (!artifactId.trim() || artifactId.includes("\0")) {
|
|
305
|
+
throw new ArtifactApiResolutionError("invalid_id");
|
|
306
|
+
}
|
|
307
|
+
const parsedReference = parseArtifactReference(artifactId);
|
|
308
|
+
if (parsedReference) {
|
|
309
|
+
if (parsedReference.kind === "artifact-id" && parsedReference.parsedId.scopeKey !== catalog.scopeKey) {
|
|
310
|
+
throw new ArtifactApiResolutionError("scope_mismatch");
|
|
311
|
+
}
|
|
312
|
+
let manifest;
|
|
313
|
+
try {
|
|
314
|
+
manifest = createArtifactRegistry().resolveArtifact(catalog.scopeKey, artifactId);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
throw new ArtifactApiResolutionError("not_found");
|
|
318
|
+
}
|
|
319
|
+
const item = findCatalogItemForReference(catalog, manifest.artifact_id) ?? itemFromManifest(catalog.scopeKey, manifest);
|
|
320
|
+
const contained = assertContainedRegularFile(catalog.scopeKey, manifest.payload_path);
|
|
321
|
+
return { item: { ...item, sizeBytes: contained.stats.size }, ...contained };
|
|
322
|
+
}
|
|
323
|
+
const item = findCatalogItemForReference(catalog, artifactId);
|
|
324
|
+
if (!item) {
|
|
325
|
+
throw new ArtifactApiResolutionError("invalid_id");
|
|
326
|
+
}
|
|
327
|
+
if (item.scopeKey !== catalog.scopeKey) {
|
|
328
|
+
throw new ArtifactApiResolutionError("scope_mismatch");
|
|
329
|
+
}
|
|
330
|
+
const contained = assertContainedRegularFile(catalog.scopeKey, catalogFilePath(catalog.scopeKey, item));
|
|
331
|
+
return { item: { ...item, sizeBytes: contained.stats.size }, ...contained };
|
|
332
|
+
}
|
|
333
|
+
function readLeadingBytes(filePath, byteLimit) {
|
|
334
|
+
const fd = openSync(filePath, "r");
|
|
335
|
+
try {
|
|
336
|
+
const buffer = Buffer.alloc(byteLimit);
|
|
337
|
+
const loadedBytes = readSync(fd, buffer, 0, byteLimit, 0);
|
|
338
|
+
return { buffer: buffer.subarray(0, loadedBytes), loadedBytes };
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
closeSync(fd);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function contentTypeForArtifactKind(kind) {
|
|
345
|
+
switch (kind) {
|
|
346
|
+
case "markdown":
|
|
347
|
+
return "text/markdown; charset=utf-8";
|
|
348
|
+
case "json":
|
|
349
|
+
return "application/json; charset=utf-8";
|
|
350
|
+
case "text":
|
|
351
|
+
case "diff":
|
|
352
|
+
return "text/plain; charset=utf-8";
|
|
353
|
+
case "binary":
|
|
354
|
+
case "unknown":
|
|
355
|
+
return "application/octet-stream";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function sanitizeDownloadFilename(value) {
|
|
359
|
+
const normalizedSeparators = normalizePathSeparators(value);
|
|
360
|
+
const basename = path.posix.basename(normalizedSeparators);
|
|
361
|
+
const sanitized = basename
|
|
362
|
+
.replace(/["\r\n\\/]/g, "_")
|
|
363
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, "_")
|
|
364
|
+
.replace(/[^\x20-\x7e]/g, "_")
|
|
365
|
+
.replace(/\s+/g, " ")
|
|
366
|
+
.trim()
|
|
367
|
+
.slice(0, 120);
|
|
368
|
+
return sanitized && sanitized !== "." && sanitized !== ".." ? sanitized : "artifact";
|
|
369
|
+
}
|
|
370
|
+
function writeArtifactBytes(response, resolved, attachment) {
|
|
371
|
+
const headers = {
|
|
372
|
+
"content-type": contentTypeForArtifactKind(resolved.item.kind),
|
|
373
|
+
"cache-control": "no-store",
|
|
374
|
+
"x-content-type-options": "nosniff",
|
|
375
|
+
};
|
|
376
|
+
if (attachment) {
|
|
377
|
+
headers["content-disposition"] = `attachment; filename="${sanitizeDownloadFilename(resolved.item.relativePath || path.basename(resolved.realPath))}"`;
|
|
378
|
+
}
|
|
379
|
+
let bytes;
|
|
380
|
+
try {
|
|
381
|
+
bytes = readFileSync(resolved.realPath);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
writeArtifactApiError(response, "read_failed", "Artifact content could not be read.", safeArtifactMetadata(resolved.item));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
response.writeHead(200, headers);
|
|
388
|
+
response.end(bytes);
|
|
389
|
+
}
|
|
390
|
+
function previewJsonArtifact(resolved) {
|
|
391
|
+
if (resolved.stats.size <= MAX_JSON_PARSE_SIZE) {
|
|
392
|
+
const loadedBytes = Number(resolved.stats.size);
|
|
393
|
+
const content = readFileSync(resolved.realPath).toString("utf8");
|
|
394
|
+
const contentBytes = Buffer.from(content, "utf8");
|
|
395
|
+
if (contentBytes.length > MAX_PREVIEW_SIZE) {
|
|
396
|
+
return {
|
|
397
|
+
content: contentBytes.subarray(0, MAX_PREVIEW_SIZE).toString("utf8"),
|
|
398
|
+
loadedBytes,
|
|
399
|
+
truncated: true,
|
|
400
|
+
jsonParseSafe: false,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return { content, loadedBytes, truncated: false, jsonParseSafe: true };
|
|
404
|
+
}
|
|
405
|
+
const { buffer, loadedBytes } = readLeadingBytes(resolved.realPath, MAX_PREVIEW_SIZE);
|
|
406
|
+
return {
|
|
407
|
+
content: buffer.toString("utf8"),
|
|
408
|
+
loadedBytes,
|
|
409
|
+
truncated: true,
|
|
410
|
+
jsonParseSafe: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function writeArtifactPreview(response, resolved) {
|
|
414
|
+
if (resolved.item.kind === "binary" || resolved.item.kind === "unknown") {
|
|
415
|
+
writeArtifactApiError(response, "unsupported_preview", "Artifact preview is not supported for this content type.", safeArtifactMetadata(resolved.item));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const preview = resolved.item.kind === "json"
|
|
420
|
+
? previewJsonArtifact(resolved)
|
|
421
|
+
: (() => {
|
|
422
|
+
const byteLimit = Math.min(Number(resolved.stats.size), MAX_PREVIEW_SIZE);
|
|
423
|
+
const { buffer, loadedBytes } = readLeadingBytes(resolved.realPath, byteLimit);
|
|
424
|
+
return {
|
|
425
|
+
content: buffer.toString("utf8"),
|
|
426
|
+
loadedBytes,
|
|
427
|
+
truncated: resolved.stats.size > loadedBytes,
|
|
428
|
+
};
|
|
429
|
+
})();
|
|
430
|
+
writeJson(response, 200, {
|
|
431
|
+
artifact: safeArtifactMetadata(resolved.item),
|
|
432
|
+
renderKind: resolved.item.kind,
|
|
433
|
+
kind: resolved.item.kind,
|
|
434
|
+
encoding: "utf-8",
|
|
435
|
+
content: preview.content,
|
|
436
|
+
truncated: preview.truncated,
|
|
437
|
+
sizeBytes: resolved.stats.size,
|
|
438
|
+
loadedBytes: preview.loadedBytes,
|
|
439
|
+
...(resolved.item.kind === "json" ? { jsonParseSafe: preview.jsonParseSafe } : {}),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
writeArtifactApiError(response, "read_failed", "Artifact preview could not be read.", safeArtifactMetadata(resolved.item));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function handleArtifactApiRequest(request, response, options, auth) {
|
|
447
|
+
if (request.method !== "GET") {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
const route = parseArtifactApiRoute(request.url);
|
|
451
|
+
if (!route) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
if (!isAuthorized(request, auth)) {
|
|
455
|
+
writeAuthRequired(response);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
if (route.kind === "list") {
|
|
459
|
+
if (!route.scopeKey) {
|
|
460
|
+
writeArtifactApiError(response, "missing_scope", "A scope query parameter is required.");
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
const primaryRunId = route.runIds[0];
|
|
464
|
+
void loadArtifactCatalog(options, {
|
|
465
|
+
scopeKey: route.scopeKey,
|
|
466
|
+
...(route.runIds.length === 1 && primaryRunId ? { runId: primaryRunId, runIds: route.runIds } : {}),
|
|
467
|
+
...(route.runIds.length > 1 ? { runIds: route.runIds } : {}),
|
|
468
|
+
})
|
|
469
|
+
.then((catalog) => {
|
|
470
|
+
if (!catalog) {
|
|
471
|
+
writeArtifactApiError(response, "not_found", "Artifact catalog provider is not configured.");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (catalog.scopeKey !== route.scopeKey) {
|
|
475
|
+
writeArtifactApiError(response, "scope_mismatch", "Requested scope does not match the active Web UI scope.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
writeJson(response, 200, filterCatalog(catalog, route.scopeKey, route.runIds));
|
|
479
|
+
})
|
|
480
|
+
.catch(() => {
|
|
481
|
+
writeArtifactApiError(response, "read_failed", "Artifact catalog could not be loaded.");
|
|
482
|
+
});
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if (!route.artifactId) {
|
|
486
|
+
writeArtifactApiError(response, "invalid_id", "Artifact identifier is invalid.");
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
const primaryRunId = route.runIds[0];
|
|
490
|
+
void loadArtifactCatalog(options, {
|
|
491
|
+
...(route.scopeKey ? { scopeKey: route.scopeKey } : {}),
|
|
492
|
+
...(route.runIds.length === 1 && primaryRunId ? { runId: primaryRunId, runIds: route.runIds } : {}),
|
|
493
|
+
...(route.runIds.length > 1 ? { runIds: route.runIds } : {}),
|
|
494
|
+
})
|
|
495
|
+
.then((catalog) => {
|
|
496
|
+
if (!catalog) {
|
|
497
|
+
writeArtifactApiError(response, "not_found", "Artifact catalog provider is not configured.");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (route.scopeKey && catalog.scopeKey !== route.scopeKey) {
|
|
501
|
+
writeArtifactApiError(response, "scope_mismatch", "Requested scope does not match the active Web UI scope.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
let resolved;
|
|
505
|
+
try {
|
|
506
|
+
resolved = resolveArtifactRequest(catalog, route.artifactId);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
const code = error instanceof ArtifactApiResolutionError ? error.code : "read_failed";
|
|
510
|
+
writeArtifactApiError(response, code, code === "invalid_id" ? "Artifact identifier is invalid." : "Artifact could not be resolved.");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (route.action === "preview") {
|
|
514
|
+
writeArtifactPreview(response, resolved);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
writeArtifactBytes(response, resolved, route.action === "download");
|
|
518
|
+
})
|
|
519
|
+
.catch(() => {
|
|
520
|
+
writeArtifactApiError(response, "read_failed", "Artifact could not be loaded.");
|
|
521
|
+
});
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
function htmlShell() {
|
|
525
|
+
return `<!doctype html>
|
|
526
|
+
<html lang="en">
|
|
527
|
+
<head>
|
|
528
|
+
<meta charset="utf-8">
|
|
529
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
530
|
+
<title>AgentWeaver Web UI</title>
|
|
531
|
+
<style>
|
|
532
|
+
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
533
|
+
body { margin: 0; background: #f6f7f9; color: #172026; }
|
|
534
|
+
main { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
|
535
|
+
header { display: flex; justify-content: space-between; gap: 16px; align-items: center; border-bottom: 1px solid #d8dee6; padding-bottom: 12px; }
|
|
536
|
+
h1 { margin: 0; font-size: 24px; font-weight: 650; letter-spacing: 0; }
|
|
537
|
+
button { border: 1px solid #b8c2cc; background: #ffffff; color: #172026; border-radius: 6px; padding: 8px 12px; cursor: pointer; }
|
|
538
|
+
section { display: grid; gap: 8px; }
|
|
539
|
+
pre, textarea { border: 1px solid #d8dee6; border-radius: 6px; background: #ffffff; padding: 12px; white-space: pre-wrap; overflow: auto; }
|
|
540
|
+
pre { min-height: 96px; }
|
|
541
|
+
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 0.45fr); gap: 16px; align-items: start; }
|
|
542
|
+
.muted { color: #5d6875; }
|
|
543
|
+
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
544
|
+
label { display: grid; gap: 4px; font-size: 14px; }
|
|
545
|
+
input, textarea, select { font: inherit; border: 1px solid #b8c2cc; border-radius: 6px; padding: 8px; background: #ffffff; color: #172026; }
|
|
546
|
+
@media (prefers-color-scheme: dark) {
|
|
547
|
+
body { background: #101418; color: #eef2f6; }
|
|
548
|
+
header, pre, textarea, input, select, button { border-color: #34404c; }
|
|
549
|
+
pre, textarea, input, select, button { background: #171d23; color: #eef2f6; }
|
|
550
|
+
.muted { color: #9aa6b2; }
|
|
551
|
+
}
|
|
552
|
+
@media (max-width: 760px) { main { padding: 16px; } .grid { grid-template-columns: 1fr; } }
|
|
553
|
+
</style>
|
|
554
|
+
</head>
|
|
555
|
+
<body>
|
|
556
|
+
<main>
|
|
557
|
+
<header>
|
|
558
|
+
<div>
|
|
559
|
+
<h1>AgentWeaver Web UI</h1>
|
|
560
|
+
<div id="scope" class="muted">Connecting...</div>
|
|
561
|
+
</div>
|
|
562
|
+
<div class="row">
|
|
563
|
+
<button id="help">Help</button>
|
|
564
|
+
<button id="clear-log">Clear Log</button>
|
|
565
|
+
</div>
|
|
566
|
+
</header>
|
|
567
|
+
<div class="grid">
|
|
568
|
+
<section>
|
|
569
|
+
<h2>Summary</h2>
|
|
570
|
+
<pre id="summary">Task summary is not available yet.</pre>
|
|
571
|
+
<h2>Activity</h2>
|
|
572
|
+
<pre id="logs"></pre>
|
|
573
|
+
</section>
|
|
574
|
+
<section>
|
|
575
|
+
<h2>Action</h2>
|
|
576
|
+
<div id="flows" class="row"></div>
|
|
577
|
+
<div id="action" class="muted">No action is pending.</div>
|
|
578
|
+
</section>
|
|
579
|
+
</div>
|
|
580
|
+
</main>
|
|
581
|
+
<script>
|
|
582
|
+
const scope = document.getElementById("scope");
|
|
583
|
+
const summary = document.getElementById("summary");
|
|
584
|
+
const logs = document.getElementById("logs");
|
|
585
|
+
const action = document.getElementById("action");
|
|
586
|
+
const flows = document.getElementById("flows");
|
|
587
|
+
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/__agentweaver/ws");
|
|
588
|
+
let viewModel = null;
|
|
589
|
+
function send(message) { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message)); }
|
|
590
|
+
function appendLogLine(line) {
|
|
591
|
+
logs.textContent += (logs.textContent ? "\\n" : "") + line;
|
|
592
|
+
}
|
|
593
|
+
function renderState(next) {
|
|
594
|
+
viewModel = next;
|
|
595
|
+
scope.textContent = next.header || next.title || "AgentWeaver";
|
|
596
|
+
summary.textContent = next.summaryText || "Task summary is not available yet.";
|
|
597
|
+
logs.textContent = next.logText || "";
|
|
598
|
+
flows.innerHTML = "";
|
|
599
|
+
for (const [index, flow] of (next.flowItems || []).entries()) {
|
|
600
|
+
const button = document.createElement("button");
|
|
601
|
+
button.textContent = flow.label;
|
|
602
|
+
button.title = flow.key;
|
|
603
|
+
button.onclick = () => send({ type: "flow.select", index });
|
|
604
|
+
button.ondblclick = () => {
|
|
605
|
+
if (flow.key.startsWith("folder:")) send({ type: "folder.toggle", key: flow.key });
|
|
606
|
+
else send({ type: "run.openConfirm", key: flow.key });
|
|
607
|
+
};
|
|
608
|
+
flows.append(button);
|
|
609
|
+
}
|
|
610
|
+
renderAction();
|
|
611
|
+
}
|
|
612
|
+
function renderAction() {
|
|
613
|
+
action.innerHTML = "";
|
|
614
|
+
if (!viewModel) {
|
|
615
|
+
action.textContent = "No action is pending.";
|
|
616
|
+
action.className = "muted";
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (viewModel.confirmation || viewModel.confirmText) {
|
|
620
|
+
const confirmation = viewModel.confirmation;
|
|
621
|
+
const label = document.createElement("div");
|
|
622
|
+
label.textContent = confirmation ? confirmation.text : viewModel.confirmText;
|
|
623
|
+
const row = document.createElement("div");
|
|
624
|
+
row.className = "row";
|
|
625
|
+
const actions = confirmation ? confirmation.actions : ["resume", "continue", "restart", "stop", "ok", "cancel"].filter((name) => viewModel.confirmText.toLowerCase().includes(name === "ok" ? "ok" : name));
|
|
626
|
+
for (const name of actions) {
|
|
627
|
+
const button = document.createElement("button");
|
|
628
|
+
button.textContent = name === "ok" ? "OK" : name[0].toUpperCase() + name.slice(1);
|
|
629
|
+
button.onclick = () => {
|
|
630
|
+
send({ type: "confirm.select", action: name });
|
|
631
|
+
send({ type: "confirm.accept" });
|
|
632
|
+
};
|
|
633
|
+
row.append(button);
|
|
634
|
+
}
|
|
635
|
+
if (!actions.includes("cancel")) {
|
|
636
|
+
const cancel = document.createElement("button");
|
|
637
|
+
cancel.textContent = "Cancel";
|
|
638
|
+
cancel.onclick = () => send({ type: "confirm.cancel" });
|
|
639
|
+
row.append(cancel);
|
|
640
|
+
}
|
|
641
|
+
action.append(label, row);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (viewModel.form) {
|
|
645
|
+
const formModel = viewModel.form;
|
|
646
|
+
const form = document.createElement("form");
|
|
647
|
+
const title = document.createElement("strong");
|
|
648
|
+
title.textContent = formModel.definition.title;
|
|
649
|
+
form.append(title);
|
|
650
|
+
for (const field of formModel.fields || formModel.definition.fields) {
|
|
651
|
+
const label = document.createElement("label");
|
|
652
|
+
label.textContent = field.label;
|
|
653
|
+
let input;
|
|
654
|
+
if (field.type === "boolean") {
|
|
655
|
+
input = document.createElement("input");
|
|
656
|
+
input.type = "checkbox";
|
|
657
|
+
input.checked = Boolean(formModel.values[field.id]);
|
|
658
|
+
} else if (field.type === "text") {
|
|
659
|
+
input = document.createElement(field.multiline ? "textarea" : "input");
|
|
660
|
+
input.value = String(formModel.values[field.id] || "");
|
|
661
|
+
} else {
|
|
662
|
+
input = document.createElement("select");
|
|
663
|
+
input.multiple = field.type === "multi-select";
|
|
664
|
+
for (const option of field.options || []) {
|
|
665
|
+
const opt = document.createElement("option");
|
|
666
|
+
opt.value = option.value;
|
|
667
|
+
opt.textContent = option.label;
|
|
668
|
+
const current = formModel.values[field.id];
|
|
669
|
+
opt.selected = Array.isArray(current) ? current.includes(option.value) : current === option.value;
|
|
670
|
+
input.append(opt);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
input.dataset.fieldId = field.id;
|
|
674
|
+
input.dataset.fieldType = field.type;
|
|
675
|
+
label.append(input);
|
|
676
|
+
form.append(label);
|
|
677
|
+
}
|
|
678
|
+
const row = document.createElement("div");
|
|
679
|
+
row.className = "row";
|
|
680
|
+
const submit = document.createElement("button");
|
|
681
|
+
submit.textContent = formModel.definition.submitLabel || "Submit";
|
|
682
|
+
const cancel = document.createElement("button");
|
|
683
|
+
cancel.type = "button";
|
|
684
|
+
cancel.textContent = "Cancel";
|
|
685
|
+
cancel.onclick = () => send({ type: "form.cancel" });
|
|
686
|
+
row.append(submit, cancel);
|
|
687
|
+
form.append(row);
|
|
688
|
+
function collectValues() {
|
|
689
|
+
const values = {};
|
|
690
|
+
for (const el of form.querySelectorAll("[data-field-id]")) {
|
|
691
|
+
if (el.dataset.fieldType === "boolean") values[el.dataset.fieldId] = el.checked;
|
|
692
|
+
else if (el.dataset.fieldType === "multi-select") values[el.dataset.fieldId] = Array.from(el.selectedOptions).map((option) => option.value);
|
|
693
|
+
else values[el.dataset.fieldId] = el.value;
|
|
694
|
+
}
|
|
695
|
+
return values;
|
|
696
|
+
}
|
|
697
|
+
form.oninput = (event) => {
|
|
698
|
+
const target = event.target;
|
|
699
|
+
if (target && target.dataset && target.dataset.fieldId) {
|
|
700
|
+
const fieldId = target.dataset.fieldId;
|
|
701
|
+
const values = collectValues();
|
|
702
|
+
send({ type: "form.fieldUpdate", fieldId, value: values[fieldId] });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
send({ type: "form.update", values: collectValues() });
|
|
706
|
+
};
|
|
707
|
+
form.onsubmit = (event) => {
|
|
708
|
+
event.preventDefault();
|
|
709
|
+
send({ type: "form.submit", values: collectValues() });
|
|
710
|
+
};
|
|
711
|
+
action.append(form);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const row = document.createElement("div");
|
|
715
|
+
row.className = "row";
|
|
716
|
+
const run = document.createElement("button");
|
|
717
|
+
run.textContent = "Run Selected";
|
|
718
|
+
run.onclick = () => send({ type: "run.openConfirm" });
|
|
719
|
+
const interrupt = document.createElement("button");
|
|
720
|
+
interrupt.textContent = "Interrupt";
|
|
721
|
+
interrupt.onclick = () => send({ type: "interrupt.openConfirm" });
|
|
722
|
+
row.append(run, interrupt);
|
|
723
|
+
action.append(row);
|
|
724
|
+
action.className = "muted";
|
|
725
|
+
}
|
|
726
|
+
ws.onmessage = (event) => {
|
|
727
|
+
const message = JSON.parse(event.data);
|
|
728
|
+
if (message.type === "snapshot") renderState(message.viewModel);
|
|
729
|
+
if (message.type === "log.append") for (const line of message.appendedLines) appendLogLine(line);
|
|
730
|
+
if (message.type === "error") appendLogLine("[protocol] " + message.message);
|
|
731
|
+
if (message.type === "closed") appendLogLine("[closed] " + (message.reason || "Session closed."));
|
|
732
|
+
};
|
|
733
|
+
document.getElementById("help").onclick = () => send({ type: "help.toggle" });
|
|
734
|
+
document.getElementById("clear-log").onclick = () => send({ type: "log.clear" });
|
|
735
|
+
</script>
|
|
736
|
+
</body>
|
|
737
|
+
</html>`;
|
|
738
|
+
}
|
|
739
|
+
function acceptKey(key) {
|
|
740
|
+
return createHash("sha1")
|
|
741
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
742
|
+
.digest("base64");
|
|
743
|
+
}
|
|
744
|
+
function encodeFrame(payload) {
|
|
745
|
+
const data = Buffer.from(payload);
|
|
746
|
+
if (data.length < 126) {
|
|
747
|
+
return Buffer.concat([Buffer.from([0x81, data.length]), data]);
|
|
748
|
+
}
|
|
749
|
+
if (data.length <= 0xffff) {
|
|
750
|
+
const header = Buffer.alloc(4);
|
|
751
|
+
header[0] = 0x81;
|
|
752
|
+
header[1] = 126;
|
|
753
|
+
header.writeUInt16BE(data.length, 2);
|
|
754
|
+
return Buffer.concat([header, data]);
|
|
755
|
+
}
|
|
756
|
+
const header = Buffer.alloc(10);
|
|
757
|
+
header[0] = 0x81;
|
|
758
|
+
header[1] = 127;
|
|
759
|
+
header.writeBigUInt64BE(BigInt(data.length), 2);
|
|
760
|
+
return Buffer.concat([header, data]);
|
|
761
|
+
}
|
|
762
|
+
function decodeFrames(buffer) {
|
|
763
|
+
const messages = [];
|
|
764
|
+
let offset = 0;
|
|
765
|
+
let close = false;
|
|
766
|
+
while (buffer.length - offset >= 2) {
|
|
767
|
+
const first = buffer[offset] ?? 0;
|
|
768
|
+
const second = buffer[offset + 1] ?? 0;
|
|
769
|
+
const opcode = first & 0x0f;
|
|
770
|
+
const masked = (second & 0x80) !== 0;
|
|
771
|
+
let length = second & 0x7f;
|
|
772
|
+
let headerLength = 2;
|
|
773
|
+
if (length === 126) {
|
|
774
|
+
if (buffer.length - offset < 4) {
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
length = buffer.readUInt16BE(offset + 2);
|
|
778
|
+
headerLength = 4;
|
|
779
|
+
}
|
|
780
|
+
else if (length === 127) {
|
|
781
|
+
if (buffer.length - offset < 10) {
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
const longLength = buffer.readBigUInt64BE(offset + 2);
|
|
785
|
+
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
786
|
+
close = true;
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
length = Number(longLength);
|
|
790
|
+
headerLength = 10;
|
|
791
|
+
}
|
|
792
|
+
const maskLength = masked ? 4 : 0;
|
|
793
|
+
const frameLength = headerLength + maskLength + length;
|
|
794
|
+
if (buffer.length - offset < frameLength) {
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
if (opcode === 0x8) {
|
|
798
|
+
close = true;
|
|
799
|
+
offset += frameLength;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (opcode === 0x1) {
|
|
803
|
+
const mask = masked ? buffer.subarray(offset + headerLength, offset + headerLength + 4) : null;
|
|
804
|
+
const payload = Buffer.from(buffer.subarray(offset + headerLength + maskLength, offset + frameLength));
|
|
805
|
+
if (mask) {
|
|
806
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
807
|
+
payload[index] = (payload[index] ?? 0) ^ (mask[index % 4] ?? 0);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
messages.push(payload.toString("utf8"));
|
|
811
|
+
}
|
|
812
|
+
offset += frameLength;
|
|
813
|
+
}
|
|
814
|
+
return { messages, rest: buffer.subarray(offset), close };
|
|
815
|
+
}
|
|
816
|
+
function defaultOpenBrowser(url) {
|
|
817
|
+
return new Promise((resolve, reject) => {
|
|
818
|
+
const platform = process.platform;
|
|
819
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
820
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
821
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
822
|
+
child.once("error", reject);
|
|
823
|
+
child.once("spawn", () => {
|
|
824
|
+
child.unref();
|
|
825
|
+
resolve();
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function formatHostForUrl(host) {
|
|
830
|
+
if (host.includes(":") && !host.startsWith("[")) {
|
|
831
|
+
return `[${host}]`;
|
|
832
|
+
}
|
|
833
|
+
return host;
|
|
834
|
+
}
|
|
835
|
+
export async function startWebServer(options) {
|
|
836
|
+
const clients = new Set();
|
|
837
|
+
const sockets = new Set();
|
|
838
|
+
const host = options.host?.trim() || "127.0.0.1";
|
|
839
|
+
const auth = options.auth;
|
|
840
|
+
let closed = false;
|
|
841
|
+
const server = http.createServer((request, response) => {
|
|
842
|
+
if (request.method === "GET" && request.url === "/__agentweaver/health") {
|
|
843
|
+
writeJson(response, 200, { ok: true });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (handleArtifactApiRequest(request, response, options, auth)) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (request.method === "GET" && request.url === "/__agentweaver/artifacts") {
|
|
850
|
+
if (!isAuthorized(request, auth)) {
|
|
851
|
+
writeAuthRequired(response);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!options.getArtifactCatalog) {
|
|
855
|
+
writeJson(response, 404, { error: "Artifact catalog provider is not configured." });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
void Promise.resolve()
|
|
859
|
+
.then(() => options.getArtifactCatalog?.())
|
|
860
|
+
.then((catalog) => {
|
|
861
|
+
writeJson(response, 200, catalog);
|
|
862
|
+
})
|
|
863
|
+
.catch((error) => {
|
|
864
|
+
writeJson(response, 500, { error: error.message });
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (request.method === "GET" && staticAssetPath(request.url)) {
|
|
869
|
+
if (!isAuthorized(request, auth)) {
|
|
870
|
+
writeAuthRequired(response);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (serveStaticAsset(request, response)) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (request.method === "POST" && request.url === "/__agentweaver/exit") {
|
|
878
|
+
if (!isAuthorized(request, auth)) {
|
|
879
|
+
writeAuthRequired(response);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
response.writeHead(202, { "content-type": "application/json; charset=utf-8" });
|
|
883
|
+
response.end(JSON.stringify({ ok: true }));
|
|
884
|
+
options.onExitRequested();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
888
|
+
response.end("Not found");
|
|
889
|
+
});
|
|
890
|
+
server.on("connection", (socket) => {
|
|
891
|
+
sockets.add(socket);
|
|
892
|
+
socket.on("close", () => sockets.delete(socket));
|
|
893
|
+
socket.on("error", () => sockets.delete(socket));
|
|
894
|
+
});
|
|
895
|
+
server.on("upgrade", (request, socket) => {
|
|
896
|
+
if (request.url !== "/__agentweaver/ws") {
|
|
897
|
+
socket.destroy();
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (!isAuthorized(request, auth)) {
|
|
901
|
+
rejectUnauthorizedUpgrade(socket);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const key = request.headers["sec-websocket-key"];
|
|
905
|
+
if (typeof key !== "string") {
|
|
906
|
+
socket.destroy();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
socket.write([
|
|
910
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
911
|
+
"Upgrade: websocket",
|
|
912
|
+
"Connection: Upgrade",
|
|
913
|
+
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
|
914
|
+
"",
|
|
915
|
+
"",
|
|
916
|
+
].join("\r\n"));
|
|
917
|
+
let buffered = Buffer.alloc(0);
|
|
918
|
+
const client = {
|
|
919
|
+
socket,
|
|
920
|
+
send: (message) => {
|
|
921
|
+
if (!socket.destroyed) {
|
|
922
|
+
socket.write(encodeFrame(JSON.stringify(message)));
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
close: () => {
|
|
926
|
+
if (!socket.destroyed) {
|
|
927
|
+
socket.end(Buffer.from([0x88, 0x00]));
|
|
928
|
+
socket.destroy();
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
clients.add(client);
|
|
933
|
+
socket.on("data", (chunk) => {
|
|
934
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
935
|
+
const decoded = decodeFrames(buffered);
|
|
936
|
+
buffered = decoded.rest;
|
|
937
|
+
if (decoded.close) {
|
|
938
|
+
client.close();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
for (const message of decoded.messages) {
|
|
942
|
+
try {
|
|
943
|
+
options.onClientAction(parseClientAction(message), client);
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
client.send({ type: "error", message: error.message });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
socket.on("close", () => clients.delete(client));
|
|
951
|
+
socket.on("error", () => clients.delete(client));
|
|
952
|
+
options.onClientConnected(client);
|
|
953
|
+
});
|
|
954
|
+
await new Promise((resolve, reject) => {
|
|
955
|
+
server.once("error", reject);
|
|
956
|
+
server.listen(0, host, () => {
|
|
957
|
+
server.off("error", reject);
|
|
958
|
+
resolve();
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
const address = server.address();
|
|
962
|
+
if (!address || typeof address === "string" || typeof address.port !== "number" || address.port <= 0) {
|
|
963
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
964
|
+
throw new Error("Unable to determine assigned Web UI port.");
|
|
965
|
+
}
|
|
966
|
+
const url = `http://${formatHostForUrl(host)}:${address.port}/`;
|
|
967
|
+
process.stdout.write(`AgentWeaver Web UI: ${url}\n`);
|
|
968
|
+
if (!options.noOpen) {
|
|
969
|
+
try {
|
|
970
|
+
await (options.openBrowser ?? defaultOpenBrowser)(url);
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
options.printInfo?.(`Warning: failed to open browser: ${error.message}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
url,
|
|
978
|
+
host,
|
|
979
|
+
broadcast(message) {
|
|
980
|
+
for (const client of clients) {
|
|
981
|
+
client.send(message);
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
close: async () => {
|
|
985
|
+
if (closed) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
closed = true;
|
|
989
|
+
for (const client of clients) {
|
|
990
|
+
client.send({ type: "closed", reason: "Server shutting down." });
|
|
991
|
+
client.close();
|
|
992
|
+
}
|
|
993
|
+
clients.clear();
|
|
994
|
+
for (const socket of sockets) {
|
|
995
|
+
if (!socket.destroyed) {
|
|
996
|
+
socket.destroy();
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
sockets.clear();
|
|
1000
|
+
await new Promise((resolve, reject) => {
|
|
1001
|
+
server.close((error) => {
|
|
1002
|
+
if (error) {
|
|
1003
|
+
reject(error);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
resolve();
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
}
|