@useorgx/openclaw-plugin 0.2.1 → 0.3.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/README.md +64 -2
- package/dashboard/dist/assets/index-BjqNjHpY.css +1 -0
- package/dashboard/dist/assets/index-DCLkU4AM.js +57 -0
- package/dashboard/dist/assets/orgx-logo-QSE5QWy4.png +0 -0
- package/dashboard/dist/brand/anthropic-mark.svg +10 -0
- package/dashboard/dist/brand/control-tower.png +0 -0
- package/dashboard/dist/brand/design-codex.png +0 -0
- package/dashboard/dist/brand/engineering-autopilot.png +0 -0
- package/dashboard/dist/brand/launch-captain.png +0 -0
- package/dashboard/dist/brand/openai-mark.svg +10 -0
- package/dashboard/dist/brand/openclaw-mark.svg +11 -0
- package/dashboard/dist/brand/orgx-logo.png +0 -0
- package/dashboard/dist/brand/pipeline-intelligence.png +0 -0
- package/dashboard/dist/brand/product-orchestrator.png +0 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/adapters/outbox.d.ts +8 -0
- package/dist/adapters/outbox.d.ts.map +1 -0
- package/dist/adapters/outbox.js +6 -0
- package/dist/adapters/outbox.js.map +1 -0
- package/dist/agent-context-store.d.ts +24 -0
- package/dist/agent-context-store.d.ts.map +1 -0
- package/dist/agent-context-store.js +110 -0
- package/dist/agent-context-store.js.map +1 -0
- package/dist/agent-run-store.d.ts +31 -0
- package/dist/agent-run-store.d.ts.map +1 -0
- package/dist/agent-run-store.js +158 -0
- package/dist/agent-run-store.js.map +1 -0
- package/dist/api.d.ts +4 -131
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -285
- package/dist/api.js.map +1 -1
- package/dist/auth-store.d.ts +20 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +154 -0
- package/dist/auth-store.js.map +1 -0
- package/dist/byok-store.d.ts +11 -0
- package/dist/byok-store.d.ts.map +1 -0
- package/dist/byok-store.js +94 -0
- package/dist/byok-store.js.map +1 -0
- package/dist/contracts/client.d.ts +154 -0
- package/dist/contracts/client.d.ts.map +1 -0
- package/dist/contracts/client.js +422 -0
- package/dist/contracts/client.js.map +1 -0
- package/dist/contracts/types.d.ts +430 -0
- package/dist/contracts/types.d.ts.map +1 -0
- package/dist/contracts/types.js +8 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/dashboard-api.d.ts +2 -7
- package/dist/dashboard-api.d.ts.map +1 -1
- package/dist/dashboard-api.js +2 -4
- package/dist/dashboard-api.js.map +1 -1
- package/dist/http-handler.d.ts +37 -3
- package/dist/http-handler.d.ts.map +1 -1
- package/dist/http-handler.js +3880 -80
- package/dist/http-handler.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1615 -41
- package/dist/index.js.map +1 -1
- package/dist/local-openclaw.d.ts +87 -0
- package/dist/local-openclaw.d.ts.map +1 -0
- package/dist/local-openclaw.js +816 -0
- package/dist/local-openclaw.js.map +1 -0
- package/dist/openclaw.plugin.json +76 -0
- package/dist/outbox.d.ts +27 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +174 -0
- package/dist/outbox.js.map +1 -0
- package/dist/snapshot-store.d.ts +10 -0
- package/dist/snapshot-store.d.ts.map +1 -0
- package/dist/snapshot-store.js +64 -0
- package/dist/snapshot-store.js.map +1 -0
- package/dist/types.d.ts +5 -320
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -4
- package/dist/types.js.map +1 -1
- package/openclaw.plugin.json +4 -3
- package/package.json +14 -2
- package/skills/orgx/SKILL.md +180 -0
- package/dashboard/dist/assets/index-C_w24A8p.css +0 -1
- package/dashboard/dist/assets/index-DfkN5JSS.js +0 -48
package/dist/http-handler.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* /orgx/api/agents → agent states
|
|
9
9
|
* /orgx/api/activity → activity feed
|
|
10
10
|
* /orgx/api/initiatives → initiative data
|
|
11
|
+
* /orgx/api/health → plugin diagnostics + outbox/sync status
|
|
11
12
|
* /orgx/api/onboarding → onboarding / config state
|
|
12
13
|
* /orgx/api/delegation/preflight → delegation preflight
|
|
13
14
|
* /orgx/api/runs/:id/checkpoints → list/create checkpoints
|
|
@@ -15,9 +16,656 @@
|
|
|
15
16
|
* /orgx/api/runs/:id/actions/:action → run control action
|
|
16
17
|
*/
|
|
17
18
|
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
-
import {
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join, extname, normalize, resolve, relative, sep } from "node:path";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
24
|
import { formatStatus, formatAgents, formatActivity, formatInitiatives, getOnboardingState, } from "./dashboard-api.js";
|
|
25
|
+
import { loadLocalOpenClawSnapshot, loadLocalTurnDetail, toLocalLiveActivity, toLocalLiveAgents, toLocalLiveInitiatives, toLocalSessionTree, } from "./local-openclaw.js";
|
|
26
|
+
import { defaultOutboxAdapter } from "./adapters/outbox.js";
|
|
27
|
+
import { readAgentContexts, upsertAgentContext } from "./agent-context-store.js";
|
|
28
|
+
import { getAgentRun, markAgentRunStopped, readAgentRuns, upsertAgentRun, } from "./agent-run-store.js";
|
|
29
|
+
import { readByokKeys, writeByokKeys } from "./byok-store.js";
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Helpers
|
|
32
|
+
// =============================================================================
|
|
33
|
+
function safeErrorMessage(err) {
|
|
34
|
+
if (err instanceof Error)
|
|
35
|
+
return err.message;
|
|
36
|
+
if (typeof err === "string")
|
|
37
|
+
return err;
|
|
38
|
+
return "Unexpected error";
|
|
39
|
+
}
|
|
40
|
+
function isUserScopedApiKey(apiKey) {
|
|
41
|
+
return apiKey.trim().toLowerCase().startsWith("oxk_");
|
|
42
|
+
}
|
|
43
|
+
function parseJsonSafe(value) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(value);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function maskSecret(value) {
|
|
52
|
+
if (!value)
|
|
53
|
+
return null;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
if (!trimmed)
|
|
56
|
+
return null;
|
|
57
|
+
if (trimmed.length <= 8)
|
|
58
|
+
return `${trimmed[0]}…${trimmed.slice(-1)}`;
|
|
59
|
+
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
|
|
60
|
+
}
|
|
61
|
+
function modelImpliesByok(model) {
|
|
62
|
+
const lower = (model ?? "").trim().toLowerCase();
|
|
63
|
+
if (!lower)
|
|
64
|
+
return false;
|
|
65
|
+
return (lower.includes("openrouter") ||
|
|
66
|
+
lower.includes("anthropic") ||
|
|
67
|
+
lower.includes("openai"));
|
|
68
|
+
}
|
|
69
|
+
async function fetchBillingStatusSafe(client) {
|
|
70
|
+
try {
|
|
71
|
+
return await client.getBillingStatus();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function resolveByokEnvOverrides() {
|
|
78
|
+
const stored = readByokKeys();
|
|
79
|
+
const env = {};
|
|
80
|
+
const openai = stored?.openaiApiKey?.trim() ?? "";
|
|
81
|
+
const anthropic = stored?.anthropicApiKey?.trim() ?? "";
|
|
82
|
+
const openrouter = stored?.openrouterApiKey?.trim() ?? "";
|
|
83
|
+
if (openai)
|
|
84
|
+
env.OPENAI_API_KEY = openai;
|
|
85
|
+
if (anthropic)
|
|
86
|
+
env.ANTHROPIC_API_KEY = anthropic;
|
|
87
|
+
if (openrouter)
|
|
88
|
+
env.OPENROUTER_API_KEY = openrouter;
|
|
89
|
+
return env;
|
|
90
|
+
}
|
|
91
|
+
async function runCommandCollect(input) {
|
|
92
|
+
const timeoutMs = input.timeoutMs ?? 10_000;
|
|
93
|
+
return await new Promise((resolve, reject) => {
|
|
94
|
+
const child = spawn(input.command, input.args, {
|
|
95
|
+
env: input.env ? { ...process.env, ...input.env } : process.env,
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
let stdout = "";
|
|
99
|
+
let stderr = "";
|
|
100
|
+
const timer = timeoutMs
|
|
101
|
+
? setTimeout(() => {
|
|
102
|
+
try {
|
|
103
|
+
child.kill("SIGKILL");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// best effort
|
|
107
|
+
}
|
|
108
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
109
|
+
}, timeoutMs)
|
|
110
|
+
: null;
|
|
111
|
+
child.stdout?.on("data", (chunk) => {
|
|
112
|
+
stdout += chunk.toString("utf8");
|
|
113
|
+
});
|
|
114
|
+
child.stderr?.on("data", (chunk) => {
|
|
115
|
+
stderr += chunk.toString("utf8");
|
|
116
|
+
});
|
|
117
|
+
child.on("error", (err) => {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
reject(err);
|
|
121
|
+
});
|
|
122
|
+
child.on("close", (code) => {
|
|
123
|
+
if (timer)
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
resolve({ stdout, stderr, exitCode: typeof code === "number" ? code : null });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async function listOpenClawAgents() {
|
|
130
|
+
const result = await runCommandCollect({
|
|
131
|
+
command: "openclaw",
|
|
132
|
+
args: ["agents", "list", "--json"],
|
|
133
|
+
timeoutMs: 5_000,
|
|
134
|
+
env: resolveByokEnvOverrides(),
|
|
135
|
+
});
|
|
136
|
+
if (result.exitCode !== 0) {
|
|
137
|
+
throw new Error(result.stderr.trim() || "openclaw agents list failed");
|
|
138
|
+
}
|
|
139
|
+
const parsed = parseJsonSafe(result.stdout);
|
|
140
|
+
if (!Array.isArray(parsed)) {
|
|
141
|
+
throw new Error("openclaw agents list returned invalid JSON");
|
|
142
|
+
}
|
|
143
|
+
return parsed.filter((entry) => Boolean(entry && typeof entry === "object"));
|
|
144
|
+
}
|
|
145
|
+
function spawnOpenClawAgentTurn(input) {
|
|
146
|
+
const args = [
|
|
147
|
+
"agent",
|
|
148
|
+
"--agent",
|
|
149
|
+
input.agentId,
|
|
150
|
+
"--session-id",
|
|
151
|
+
input.sessionId,
|
|
152
|
+
"--message",
|
|
153
|
+
input.message,
|
|
154
|
+
];
|
|
155
|
+
if (input.thinking) {
|
|
156
|
+
args.push("--thinking", input.thinking);
|
|
157
|
+
}
|
|
158
|
+
const child = spawn("openclaw", args, {
|
|
159
|
+
env: { ...process.env, ...resolveByokEnvOverrides() },
|
|
160
|
+
stdio: "ignore",
|
|
161
|
+
detached: true,
|
|
162
|
+
});
|
|
163
|
+
child.unref();
|
|
164
|
+
return { pid: child.pid ?? null };
|
|
165
|
+
}
|
|
166
|
+
function normalizeOpenClawProvider(value) {
|
|
167
|
+
const raw = (value ?? "").trim().toLowerCase();
|
|
168
|
+
if (!raw)
|
|
169
|
+
return null;
|
|
170
|
+
if (raw === "auto")
|
|
171
|
+
return null;
|
|
172
|
+
if (raw === "claude")
|
|
173
|
+
return "anthropic";
|
|
174
|
+
if (raw === "anthropic")
|
|
175
|
+
return "anthropic";
|
|
176
|
+
if (raw === "openrouter" || raw === "open-router")
|
|
177
|
+
return "openrouter";
|
|
178
|
+
if (raw === "openai")
|
|
179
|
+
return "openai";
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
async function setOpenClawAgentModel(input) {
|
|
183
|
+
const agentId = input.agentId.trim();
|
|
184
|
+
const model = input.model.trim();
|
|
185
|
+
if (!agentId || !model) {
|
|
186
|
+
throw new Error("agentId and model are required");
|
|
187
|
+
}
|
|
188
|
+
const result = await runCommandCollect({
|
|
189
|
+
command: "openclaw",
|
|
190
|
+
args: ["models", "--agent", agentId, "set", model],
|
|
191
|
+
timeoutMs: 10_000,
|
|
192
|
+
env: resolveByokEnvOverrides(),
|
|
193
|
+
});
|
|
194
|
+
if (result.exitCode !== 0) {
|
|
195
|
+
throw new Error(result.stderr.trim() || `openclaw models set failed for ${agentId}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function listOpenClawProviderModels(input) {
|
|
199
|
+
const result = await runCommandCollect({
|
|
200
|
+
command: "openclaw",
|
|
201
|
+
args: [
|
|
202
|
+
"models",
|
|
203
|
+
"--agent",
|
|
204
|
+
input.agentId,
|
|
205
|
+
"list",
|
|
206
|
+
"--provider",
|
|
207
|
+
input.provider,
|
|
208
|
+
"--json",
|
|
209
|
+
],
|
|
210
|
+
timeoutMs: 10_000,
|
|
211
|
+
env: resolveByokEnvOverrides(),
|
|
212
|
+
});
|
|
213
|
+
if (result.exitCode !== 0) {
|
|
214
|
+
throw new Error(result.stderr.trim() || "openclaw models list failed");
|
|
215
|
+
}
|
|
216
|
+
const parsed = parseJsonSafe(result.stdout);
|
|
217
|
+
if (!parsed || typeof parsed !== "object") {
|
|
218
|
+
const trimmed = result.stdout.trim();
|
|
219
|
+
if (!trimmed || /no models found/i.test(trimmed)) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
throw new Error("openclaw models list returned invalid JSON");
|
|
223
|
+
}
|
|
224
|
+
const modelsRaw = "models" in parsed && Array.isArray(parsed.models)
|
|
225
|
+
? parsed.models
|
|
226
|
+
: [];
|
|
227
|
+
return modelsRaw
|
|
228
|
+
.map((entry) => {
|
|
229
|
+
if (!entry || typeof entry !== "object")
|
|
230
|
+
return null;
|
|
231
|
+
const row = entry;
|
|
232
|
+
const key = typeof row.key === "string" ? row.key.trim() : "";
|
|
233
|
+
const tags = Array.isArray(row.tags)
|
|
234
|
+
? row.tags.filter((t) => typeof t === "string")
|
|
235
|
+
: [];
|
|
236
|
+
if (!key)
|
|
237
|
+
return null;
|
|
238
|
+
return { key, tags };
|
|
239
|
+
})
|
|
240
|
+
.filter((entry) => Boolean(entry));
|
|
241
|
+
}
|
|
242
|
+
function pickPreferredModel(models) {
|
|
243
|
+
if (models.length === 0)
|
|
244
|
+
return null;
|
|
245
|
+
const preferred = models.find((m) => m.tags.some((t) => t === "default"));
|
|
246
|
+
return preferred?.key ?? models[0]?.key ?? null;
|
|
247
|
+
}
|
|
248
|
+
async function configureOpenClawProviderRouting(input) {
|
|
249
|
+
const requestedModel = (input.requestedModel ?? "").trim() || null;
|
|
250
|
+
// Fast path: use known aliases where possible.
|
|
251
|
+
const aliasByProvider = {
|
|
252
|
+
anthropic: "opus",
|
|
253
|
+
openrouter: "sonnet",
|
|
254
|
+
openai: null,
|
|
255
|
+
};
|
|
256
|
+
const candidate = requestedModel ?? aliasByProvider[input.provider];
|
|
257
|
+
if (candidate) {
|
|
258
|
+
try {
|
|
259
|
+
await setOpenClawAgentModel({ agentId: input.agentId, model: candidate });
|
|
260
|
+
return { provider: input.provider, model: candidate };
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Fall through to discovery-based selection.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const models = await listOpenClawProviderModels({
|
|
267
|
+
agentId: input.agentId,
|
|
268
|
+
provider: input.provider,
|
|
269
|
+
});
|
|
270
|
+
const selected = pickPreferredModel(models);
|
|
271
|
+
if (!selected) {
|
|
272
|
+
throw new Error(`No ${input.provider} models configured for agent ${input.agentId}. Add a model in OpenClaw and retry.`);
|
|
273
|
+
}
|
|
274
|
+
await setOpenClawAgentModel({ agentId: input.agentId, model: selected });
|
|
275
|
+
return { provider: input.provider, model: selected };
|
|
276
|
+
}
|
|
277
|
+
function isPidAlive(pid) {
|
|
278
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
279
|
+
return false;
|
|
280
|
+
try {
|
|
281
|
+
process.kill(pid, 0);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function stopDetachedProcess(pid) {
|
|
289
|
+
const alive = isPidAlive(pid);
|
|
290
|
+
if (!alive) {
|
|
291
|
+
return { stopped: true, wasRunning: false };
|
|
292
|
+
}
|
|
293
|
+
const tryKill = (signal) => {
|
|
294
|
+
try {
|
|
295
|
+
// Detached child becomes its own process group (pgid = pid) on Unix.
|
|
296
|
+
process.kill(-pid, signal);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Fall back to direct pid kill.
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
process.kill(pid, signal);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// ignore
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
tryKill("SIGTERM");
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, 450));
|
|
311
|
+
if (isPidAlive(pid)) {
|
|
312
|
+
tryKill("SIGKILL");
|
|
313
|
+
}
|
|
314
|
+
return { stopped: !isPidAlive(pid), wasRunning: true };
|
|
315
|
+
}
|
|
316
|
+
function getScopedAgentIds(contexts) {
|
|
317
|
+
const scoped = new Set();
|
|
318
|
+
for (const [key, ctx] of Object.entries(contexts)) {
|
|
319
|
+
if (!ctx || typeof ctx !== "object")
|
|
320
|
+
continue;
|
|
321
|
+
const agentId = (ctx.agentId ?? key).trim();
|
|
322
|
+
if (!agentId)
|
|
323
|
+
continue;
|
|
324
|
+
const initiativeId = ctx.initiativeId?.trim() ?? "";
|
|
325
|
+
if (initiativeId) {
|
|
326
|
+
scoped.add(agentId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return scoped;
|
|
330
|
+
}
|
|
331
|
+
function applyAgentContextsToSessionTree(input, contexts) {
|
|
332
|
+
if (!input || !Array.isArray(input.nodes))
|
|
333
|
+
return input;
|
|
334
|
+
const groupsById = new Map();
|
|
335
|
+
for (const group of input.groups ?? []) {
|
|
336
|
+
if (!group)
|
|
337
|
+
continue;
|
|
338
|
+
groupsById.set(group.id, {
|
|
339
|
+
id: group.id,
|
|
340
|
+
label: group.label,
|
|
341
|
+
status: group.status ?? null,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const nodes = input.nodes.map((node) => {
|
|
345
|
+
const agentId = node.agentId?.trim() ?? "";
|
|
346
|
+
if (!agentId)
|
|
347
|
+
return node;
|
|
348
|
+
const ctx = contexts[agentId];
|
|
349
|
+
const initiativeId = ctx?.initiativeId?.trim() ?? "";
|
|
350
|
+
if (!initiativeId)
|
|
351
|
+
return node;
|
|
352
|
+
const groupId = initiativeId;
|
|
353
|
+
const ctxTitle = ctx.initiativeTitle?.trim() ?? "";
|
|
354
|
+
const groupLabel = ctxTitle || node.groupLabel || initiativeId;
|
|
355
|
+
const existing = groupsById.get(groupId);
|
|
356
|
+
if (!existing) {
|
|
357
|
+
groupsById.set(groupId, {
|
|
358
|
+
id: groupId,
|
|
359
|
+
label: groupLabel,
|
|
360
|
+
status: node.status ?? null,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
else if (ctxTitle && (existing.label === groupId || existing.label.startsWith("Agent "))) {
|
|
364
|
+
groupsById.set(groupId, { ...existing, label: groupLabel });
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
...node,
|
|
368
|
+
initiativeId,
|
|
369
|
+
workstreamId: ctx.workstreamId ?? node.workstreamId ?? null,
|
|
370
|
+
groupId,
|
|
371
|
+
groupLabel,
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
// Ensure every node's group exists.
|
|
375
|
+
for (const node of nodes) {
|
|
376
|
+
if (!groupsById.has(node.groupId)) {
|
|
377
|
+
groupsById.set(node.groupId, {
|
|
378
|
+
id: node.groupId,
|
|
379
|
+
label: node.groupLabel || node.groupId,
|
|
380
|
+
status: node.status ?? null,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
...input,
|
|
386
|
+
nodes,
|
|
387
|
+
groups: Array.from(groupsById.values()),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function applyAgentContextsToActivity(input, contexts) {
|
|
391
|
+
if (!Array.isArray(input))
|
|
392
|
+
return [];
|
|
393
|
+
return input.map((item) => {
|
|
394
|
+
const agentId = item.agentId?.trim() ?? "";
|
|
395
|
+
if (!agentId)
|
|
396
|
+
return item;
|
|
397
|
+
const ctx = contexts[agentId];
|
|
398
|
+
const initiativeId = ctx?.initiativeId?.trim() ?? "";
|
|
399
|
+
if (!initiativeId)
|
|
400
|
+
return item;
|
|
401
|
+
const metadata = item.metadata && typeof item.metadata === "object"
|
|
402
|
+
? { ...item.metadata }
|
|
403
|
+
: {};
|
|
404
|
+
metadata.orgx_context = {
|
|
405
|
+
initiativeId,
|
|
406
|
+
workstreamId: ctx.workstreamId ?? null,
|
|
407
|
+
taskId: ctx.taskId ?? null,
|
|
408
|
+
updatedAt: ctx.updatedAt,
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
...item,
|
|
412
|
+
initiativeId,
|
|
413
|
+
metadata,
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function mergeSessionTrees(base, extra) {
|
|
418
|
+
const seenNodes = new Set();
|
|
419
|
+
const nodes = [];
|
|
420
|
+
for (const node of base.nodes ?? []) {
|
|
421
|
+
seenNodes.add(node.id);
|
|
422
|
+
nodes.push(node);
|
|
423
|
+
}
|
|
424
|
+
for (const node of extra.nodes ?? []) {
|
|
425
|
+
if (seenNodes.has(node.id))
|
|
426
|
+
continue;
|
|
427
|
+
seenNodes.add(node.id);
|
|
428
|
+
nodes.push(node);
|
|
429
|
+
}
|
|
430
|
+
const seenEdges = new Set();
|
|
431
|
+
const edges = [];
|
|
432
|
+
for (const edge of base.edges ?? []) {
|
|
433
|
+
const key = `${edge.parentId}→${edge.childId}`;
|
|
434
|
+
seenEdges.add(key);
|
|
435
|
+
edges.push(edge);
|
|
436
|
+
}
|
|
437
|
+
for (const edge of extra.edges ?? []) {
|
|
438
|
+
const key = `${edge.parentId}→${edge.childId}`;
|
|
439
|
+
if (seenEdges.has(key))
|
|
440
|
+
continue;
|
|
441
|
+
seenEdges.add(key);
|
|
442
|
+
edges.push(edge);
|
|
443
|
+
}
|
|
444
|
+
const groupsById = new Map();
|
|
445
|
+
for (const group of base.groups ?? []) {
|
|
446
|
+
groupsById.set(group.id, group);
|
|
447
|
+
}
|
|
448
|
+
for (const group of extra.groups ?? []) {
|
|
449
|
+
const existing = groupsById.get(group.id);
|
|
450
|
+
if (!existing) {
|
|
451
|
+
groupsById.set(group.id, group);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const nextLabel = existing.label === existing.id && group.label && group.label !== group.id
|
|
455
|
+
? group.label
|
|
456
|
+
: existing.label;
|
|
457
|
+
groupsById.set(group.id, { ...existing, label: nextLabel });
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
nodes,
|
|
461
|
+
edges,
|
|
462
|
+
groups: Array.from(groupsById.values()),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function mergeActivities(base, extra, limit) {
|
|
466
|
+
const merged = [...(base ?? []), ...(extra ?? [])].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
467
|
+
const deduped = [];
|
|
468
|
+
const seen = new Set();
|
|
469
|
+
for (const item of merged) {
|
|
470
|
+
if (seen.has(item.id))
|
|
471
|
+
continue;
|
|
472
|
+
seen.add(item.id);
|
|
473
|
+
deduped.push(item);
|
|
474
|
+
if (deduped.length >= limit)
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
return deduped;
|
|
478
|
+
}
|
|
479
|
+
const ACTIVITY_HEADLINE_TIMEOUT_MS = 4_000;
|
|
480
|
+
const ACTIVITY_HEADLINE_CACHE_TTL_MS = 12 * 60 * 60_000;
|
|
481
|
+
const ACTIVITY_HEADLINE_CACHE_MAX = 1_000;
|
|
482
|
+
const ACTIVITY_HEADLINE_MAX_INPUT_CHARS = 8_000;
|
|
483
|
+
const DEFAULT_ACTIVITY_HEADLINE_MODEL = "openai/gpt-4.1-nano";
|
|
484
|
+
const activityHeadlineCache = new Map();
|
|
485
|
+
let resolvedActivitySummaryApiKey;
|
|
486
|
+
function normalizeSpaces(value) {
|
|
487
|
+
return value.replace(/\s+/g, " ").trim();
|
|
488
|
+
}
|
|
489
|
+
function stripMarkdownLite(value) {
|
|
490
|
+
return value
|
|
491
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
492
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
493
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
494
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
495
|
+
.replace(/\*([^*\n]+)\*/g, "$1")
|
|
496
|
+
.replace(/_([^_\n]+)_/g, "$1")
|
|
497
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, "$1")
|
|
498
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
499
|
+
.replace(/^\s*[-*]\s+/gm, "")
|
|
500
|
+
.replace(/^\s*\d+\.\s+/gm, "")
|
|
501
|
+
.replace(/\r\n/g, "\n")
|
|
502
|
+
.trim();
|
|
503
|
+
}
|
|
504
|
+
function cleanActivityHeadline(value) {
|
|
505
|
+
const lines = stripMarkdownLite(value)
|
|
506
|
+
.split("\n")
|
|
507
|
+
.map((line) => normalizeSpaces(line))
|
|
508
|
+
.filter((line) => line.length > 0 && !/^\|?[:\-| ]+\|?$/.test(line));
|
|
509
|
+
const headline = lines[0] ?? "";
|
|
510
|
+
if (!headline)
|
|
511
|
+
return "";
|
|
512
|
+
if (headline.length <= 108)
|
|
513
|
+
return headline;
|
|
514
|
+
return `${headline.slice(0, 107).trimEnd()}…`;
|
|
515
|
+
}
|
|
516
|
+
function heuristicActivityHeadline(text, title) {
|
|
517
|
+
const cleanedText = cleanActivityHeadline(text);
|
|
518
|
+
if (cleanedText.length > 0)
|
|
519
|
+
return cleanedText;
|
|
520
|
+
const cleanedTitle = cleanActivityHeadline(title ?? "");
|
|
521
|
+
if (cleanedTitle.length > 0)
|
|
522
|
+
return cleanedTitle;
|
|
523
|
+
return "Activity update";
|
|
524
|
+
}
|
|
525
|
+
function resolveActivitySummaryApiKey() {
|
|
526
|
+
if (resolvedActivitySummaryApiKey !== undefined) {
|
|
527
|
+
return resolvedActivitySummaryApiKey;
|
|
528
|
+
}
|
|
529
|
+
const candidates = [
|
|
530
|
+
process.env.ORGX_ACTIVITY_SUMMARY_API_KEY ?? "",
|
|
531
|
+
process.env.OPENROUTER_API_KEY ?? "",
|
|
532
|
+
];
|
|
533
|
+
const key = candidates.find((candidate) => candidate.trim().length > 0)?.trim() ?? "";
|
|
534
|
+
resolvedActivitySummaryApiKey = key || null;
|
|
535
|
+
return resolvedActivitySummaryApiKey;
|
|
536
|
+
}
|
|
537
|
+
function trimActivityHeadlineCache() {
|
|
538
|
+
while (activityHeadlineCache.size > ACTIVITY_HEADLINE_CACHE_MAX) {
|
|
539
|
+
const firstKey = activityHeadlineCache.keys().next().value;
|
|
540
|
+
if (!firstKey)
|
|
541
|
+
break;
|
|
542
|
+
activityHeadlineCache.delete(firstKey);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function extractCompletionText(payload) {
|
|
546
|
+
const choices = payload.choices;
|
|
547
|
+
if (!Array.isArray(choices) || choices.length === 0)
|
|
548
|
+
return null;
|
|
549
|
+
const first = choices[0];
|
|
550
|
+
if (!first || typeof first !== "object")
|
|
551
|
+
return null;
|
|
552
|
+
const firstRecord = first;
|
|
553
|
+
const message = firstRecord.message;
|
|
554
|
+
if (message && typeof message === "object") {
|
|
555
|
+
const content = message.content;
|
|
556
|
+
if (typeof content === "string") {
|
|
557
|
+
return content;
|
|
558
|
+
}
|
|
559
|
+
if (Array.isArray(content)) {
|
|
560
|
+
const textParts = content
|
|
561
|
+
.map((part) => {
|
|
562
|
+
if (typeof part === "string")
|
|
563
|
+
return part;
|
|
564
|
+
if (!part || typeof part !== "object")
|
|
565
|
+
return "";
|
|
566
|
+
const record = part;
|
|
567
|
+
return typeof record.text === "string" ? record.text : "";
|
|
568
|
+
})
|
|
569
|
+
.filter((part) => part.length > 0);
|
|
570
|
+
if (textParts.length > 0) {
|
|
571
|
+
return textParts.join(" ");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return pickString(firstRecord, ["text", "content"]);
|
|
576
|
+
}
|
|
577
|
+
async function summarizeActivityHeadline(input) {
|
|
578
|
+
const normalizedText = normalizeSpaces(input.text).slice(0, ACTIVITY_HEADLINE_MAX_INPUT_CHARS);
|
|
579
|
+
const normalizedTitle = normalizeSpaces(input.title ?? "");
|
|
580
|
+
const normalizedType = normalizeSpaces(input.type ?? "");
|
|
581
|
+
const heuristic = heuristicActivityHeadline(normalizedText, normalizedTitle);
|
|
582
|
+
const cacheKey = createHash("sha256")
|
|
583
|
+
.update(`${normalizedType}\n${normalizedTitle}\n${normalizedText}`)
|
|
584
|
+
.digest("hex");
|
|
585
|
+
const cached = activityHeadlineCache.get(cacheKey);
|
|
586
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
587
|
+
return { headline: cached.headline, source: cached.source, model: null };
|
|
588
|
+
}
|
|
589
|
+
const apiKey = resolveActivitySummaryApiKey();
|
|
590
|
+
if (!apiKey) {
|
|
591
|
+
activityHeadlineCache.set(cacheKey, {
|
|
592
|
+
headline: heuristic,
|
|
593
|
+
source: "heuristic",
|
|
594
|
+
expiresAt: Date.now() + ACTIVITY_HEADLINE_CACHE_TTL_MS,
|
|
595
|
+
});
|
|
596
|
+
trimActivityHeadlineCache();
|
|
597
|
+
return { headline: heuristic, source: "heuristic", model: null };
|
|
598
|
+
}
|
|
599
|
+
const controller = new AbortController();
|
|
600
|
+
const timeout = setTimeout(() => controller.abort(), ACTIVITY_HEADLINE_TIMEOUT_MS);
|
|
601
|
+
const model = process.env.ORGX_ACTIVITY_SUMMARY_MODEL?.trim() || DEFAULT_ACTIVITY_HEADLINE_MODEL;
|
|
602
|
+
const prompt = [
|
|
603
|
+
"Create one short activity title for a dashboard header.",
|
|
604
|
+
"Rules:",
|
|
605
|
+
"- Max 96 characters.",
|
|
606
|
+
"- Keep key numbers/status markers (for example: 15 tasks, 0 blocked).",
|
|
607
|
+
"- No markdown, no quotes, no trailing period unless needed.",
|
|
608
|
+
"- Prefer plain language over jargon.",
|
|
609
|
+
"",
|
|
610
|
+
`Type: ${normalizedType || "activity"}`,
|
|
611
|
+
normalizedTitle ? `Current title: ${normalizedTitle}` : "",
|
|
612
|
+
"Full detail:",
|
|
613
|
+
normalizedText,
|
|
614
|
+
]
|
|
615
|
+
.filter(Boolean)
|
|
616
|
+
.join("\n");
|
|
617
|
+
try {
|
|
618
|
+
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: {
|
|
621
|
+
"Content-Type": "application/json",
|
|
622
|
+
Authorization: `Bearer ${apiKey}`,
|
|
623
|
+
},
|
|
624
|
+
body: JSON.stringify({
|
|
625
|
+
model,
|
|
626
|
+
temperature: 0.1,
|
|
627
|
+
max_tokens: 48,
|
|
628
|
+
messages: [
|
|
629
|
+
{
|
|
630
|
+
role: "system",
|
|
631
|
+
content: "You write concise activity headers for operational dashboards. Return only the header text.",
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
role: "user",
|
|
635
|
+
content: prompt,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
}),
|
|
639
|
+
signal: controller.signal,
|
|
640
|
+
});
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
throw new Error(`headline model request failed (${response.status})`);
|
|
643
|
+
}
|
|
644
|
+
const payload = (await response.json());
|
|
645
|
+
const generated = cleanActivityHeadline(extractCompletionText(payload) ?? "");
|
|
646
|
+
const headline = generated || heuristic;
|
|
647
|
+
const source = generated ? "llm" : "heuristic";
|
|
648
|
+
activityHeadlineCache.set(cacheKey, {
|
|
649
|
+
headline,
|
|
650
|
+
source,
|
|
651
|
+
expiresAt: Date.now() + ACTIVITY_HEADLINE_CACHE_TTL_MS,
|
|
652
|
+
});
|
|
653
|
+
trimActivityHeadlineCache();
|
|
654
|
+
return { headline, source, model };
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
activityHeadlineCache.set(cacheKey, {
|
|
658
|
+
headline: heuristic,
|
|
659
|
+
source: "heuristic",
|
|
660
|
+
expiresAt: Date.now() + ACTIVITY_HEADLINE_CACHE_TTL_MS,
|
|
661
|
+
});
|
|
662
|
+
trimActivityHeadlineCache();
|
|
663
|
+
return { headline: heuristic, source: "heuristic", model: null };
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
clearTimeout(timeout);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
21
669
|
// =============================================================================
|
|
22
670
|
// Content-Type mapping
|
|
23
671
|
// =============================================================================
|
|
@@ -42,19 +690,85 @@ function contentType(filePath) {
|
|
|
42
690
|
return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
43
691
|
}
|
|
44
692
|
// =============================================================================
|
|
45
|
-
// CORS
|
|
693
|
+
// CORS + response hardening
|
|
46
694
|
// =============================================================================
|
|
47
695
|
const CORS_HEADERS = {
|
|
48
|
-
"Access-Control-Allow-
|
|
49
|
-
"Access-Control-Allow-
|
|
50
|
-
|
|
696
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
|
|
697
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OrgX-Api-Key, X-API-Key, X-OrgX-User-Id",
|
|
698
|
+
Vary: "Origin",
|
|
699
|
+
};
|
|
700
|
+
const SECURITY_HEADERS = {
|
|
701
|
+
"X-Content-Type-Options": "nosniff",
|
|
702
|
+
"X-Frame-Options": "DENY",
|
|
703
|
+
"Referrer-Policy": "same-origin",
|
|
704
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
51
705
|
};
|
|
706
|
+
function normalizeHost(value) {
|
|
707
|
+
return value.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
708
|
+
}
|
|
709
|
+
function isLoopbackHost(hostname) {
|
|
710
|
+
const host = normalizeHost(hostname);
|
|
711
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
712
|
+
}
|
|
713
|
+
function isTrustedOrigin(origin) {
|
|
714
|
+
try {
|
|
715
|
+
const parsed = new URL(origin);
|
|
716
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
return isLoopbackHost(parsed.hostname);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function isTrustedRequestSource(headers) {
|
|
726
|
+
const fetchSite = pickHeaderString(headers, ["sec-fetch-site"]);
|
|
727
|
+
if (fetchSite) {
|
|
728
|
+
const normalizedFetchSite = fetchSite.trim().toLowerCase();
|
|
729
|
+
if (normalizedFetchSite !== "same-origin" &&
|
|
730
|
+
normalizedFetchSite !== "same-site" &&
|
|
731
|
+
normalizedFetchSite !== "none") {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const origin = pickHeaderString(headers, ["origin"]);
|
|
736
|
+
if (origin) {
|
|
737
|
+
return isTrustedOrigin(origin);
|
|
738
|
+
}
|
|
739
|
+
const referer = pickHeaderString(headers, ["referer"]);
|
|
740
|
+
if (referer) {
|
|
741
|
+
try {
|
|
742
|
+
return isTrustedOrigin(new URL(referer).origin);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
const STREAM_IDLE_TIMEOUT_MS = 60_000;
|
|
52
751
|
// =============================================================================
|
|
53
752
|
// Resolve the dashboard/dist/ directory relative to this file
|
|
54
753
|
// =============================================================================
|
|
55
754
|
const __filename = fileURLToPath(import.meta.url);
|
|
56
755
|
// src/http-handler.ts → up to plugin root → dashboard/dist
|
|
57
756
|
const DIST_DIR = join(__filename, "..", "..", "dashboard", "dist");
|
|
757
|
+
const RESOLVED_DIST_DIR = resolve(DIST_DIR);
|
|
758
|
+
const RESOLVED_DIST_ASSETS_DIR = resolve(DIST_DIR, "assets");
|
|
759
|
+
function resolveSafeDistPath(subPath) {
|
|
760
|
+
if (!subPath || subPath.includes("\0"))
|
|
761
|
+
return null;
|
|
762
|
+
const normalized = normalize(subPath).replace(/^([/\\])+/, "");
|
|
763
|
+
if (!normalized || normalized === ".")
|
|
764
|
+
return null;
|
|
765
|
+
const candidate = resolve(DIST_DIR, normalized);
|
|
766
|
+
const rel = relative(RESOLVED_DIST_DIR, candidate);
|
|
767
|
+
if (!rel || rel === "." || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
return candidate;
|
|
771
|
+
}
|
|
58
772
|
// =============================================================================
|
|
59
773
|
// Helpers
|
|
60
774
|
// =============================================================================
|
|
@@ -62,6 +776,7 @@ function sendJson(res, status, data) {
|
|
|
62
776
|
const body = JSON.stringify(data);
|
|
63
777
|
res.writeHead(status, {
|
|
64
778
|
"Content-Type": "application/json; charset=utf-8",
|
|
779
|
+
...SECURITY_HEADERS,
|
|
65
780
|
...CORS_HEADERS,
|
|
66
781
|
});
|
|
67
782
|
res.end(body);
|
|
@@ -72,6 +787,7 @@ function sendFile(res, filePath, cacheControl) {
|
|
|
72
787
|
res.writeHead(200, {
|
|
73
788
|
"Content-Type": contentType(filePath),
|
|
74
789
|
"Cache-Control": cacheControl,
|
|
790
|
+
...SECURITY_HEADERS,
|
|
75
791
|
...CORS_HEADERS,
|
|
76
792
|
});
|
|
77
793
|
res.end(content);
|
|
@@ -83,6 +799,7 @@ function sendFile(res, filePath, cacheControl) {
|
|
|
83
799
|
function send404(res) {
|
|
84
800
|
res.writeHead(404, {
|
|
85
801
|
"Content-Type": "text/plain; charset=utf-8",
|
|
802
|
+
...SECURITY_HEADERS,
|
|
86
803
|
...CORS_HEADERS,
|
|
87
804
|
});
|
|
88
805
|
res.end("Not Found");
|
|
@@ -95,6 +812,7 @@ function sendIndexHtml(res) {
|
|
|
95
812
|
else {
|
|
96
813
|
res.writeHead(503, {
|
|
97
814
|
"Content-Type": "text/html; charset=utf-8",
|
|
815
|
+
...SECURITY_HEADERS,
|
|
98
816
|
...CORS_HEADERS,
|
|
99
817
|
});
|
|
100
818
|
res.end("<html><body><h1>Dashboard not built</h1>" +
|
|
@@ -127,11 +845,120 @@ function parseJsonBody(body) {
|
|
|
127
845
|
return {};
|
|
128
846
|
}
|
|
129
847
|
}
|
|
848
|
+
if (body instanceof Uint8Array) {
|
|
849
|
+
try {
|
|
850
|
+
const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
|
|
851
|
+
return typeof parsed === "object" && parsed !== null
|
|
852
|
+
? parsed
|
|
853
|
+
: {};
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return {};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (body instanceof ArrayBuffer) {
|
|
860
|
+
try {
|
|
861
|
+
const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
|
|
862
|
+
return typeof parsed === "object" && parsed !== null
|
|
863
|
+
? parsed
|
|
864
|
+
: {};
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
return {};
|
|
868
|
+
}
|
|
869
|
+
}
|
|
130
870
|
if (typeof body === "object") {
|
|
131
871
|
return body;
|
|
132
872
|
}
|
|
133
873
|
return {};
|
|
134
874
|
}
|
|
875
|
+
const MAX_JSON_BODY_BYTES = 1_000_000;
|
|
876
|
+
const JSON_BODY_TIMEOUT_MS = 2_000;
|
|
877
|
+
function chunkToBuffer(chunk) {
|
|
878
|
+
if (!chunk)
|
|
879
|
+
return Buffer.alloc(0);
|
|
880
|
+
if (Buffer.isBuffer(chunk))
|
|
881
|
+
return chunk;
|
|
882
|
+
if (typeof chunk === "string")
|
|
883
|
+
return Buffer.from(chunk, "utf8");
|
|
884
|
+
if (chunk instanceof Uint8Array)
|
|
885
|
+
return Buffer.from(chunk);
|
|
886
|
+
try {
|
|
887
|
+
return Buffer.from(JSON.stringify(chunk), "utf8");
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
return Buffer.from(String(chunk), "utf8");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async function readRequestBodyBuffer(req) {
|
|
894
|
+
const on = req.on ? req.on.bind(req) : null;
|
|
895
|
+
if (!on)
|
|
896
|
+
return null;
|
|
897
|
+
return await new Promise((resolve) => {
|
|
898
|
+
const chunks = [];
|
|
899
|
+
let totalBytes = 0;
|
|
900
|
+
let finished = false;
|
|
901
|
+
const finish = (buffer) => {
|
|
902
|
+
if (finished)
|
|
903
|
+
return;
|
|
904
|
+
finished = true;
|
|
905
|
+
clearTimeout(timer);
|
|
906
|
+
resolve(buffer);
|
|
907
|
+
};
|
|
908
|
+
const timer = setTimeout(() => finish(null), JSON_BODY_TIMEOUT_MS);
|
|
909
|
+
on("data", (chunk) => {
|
|
910
|
+
const buf = chunkToBuffer(chunk);
|
|
911
|
+
if (buf.length === 0)
|
|
912
|
+
return;
|
|
913
|
+
totalBytes += buf.length;
|
|
914
|
+
if (totalBytes > MAX_JSON_BODY_BYTES) {
|
|
915
|
+
finish(null);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
chunks.push(buf);
|
|
919
|
+
});
|
|
920
|
+
const onDone = () => {
|
|
921
|
+
if (chunks.length === 0) {
|
|
922
|
+
finish(Buffer.alloc(0));
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
finish(Buffer.concat(chunks, totalBytes));
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
const once = (req.once ?? req.on)?.bind(req) ?? null;
|
|
929
|
+
if (once) {
|
|
930
|
+
once("end", onDone);
|
|
931
|
+
once("error", () => finish(null));
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
on("end", onDone);
|
|
935
|
+
on("error", () => finish(null));
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
async function parseJsonRequest(req) {
|
|
940
|
+
const body = req.body;
|
|
941
|
+
if (typeof body === "string" && body.length > 0) {
|
|
942
|
+
return parseJsonBody(body);
|
|
943
|
+
}
|
|
944
|
+
if (Buffer.isBuffer(body) && body.length > 0) {
|
|
945
|
+
return parseJsonBody(body);
|
|
946
|
+
}
|
|
947
|
+
if (body instanceof Uint8Array && body.byteLength > 0) {
|
|
948
|
+
return parseJsonBody(body);
|
|
949
|
+
}
|
|
950
|
+
if (body instanceof ArrayBuffer && body.byteLength > 0) {
|
|
951
|
+
return parseJsonBody(body);
|
|
952
|
+
}
|
|
953
|
+
if (body && typeof body === "object" && !Buffer.isBuffer(body)) {
|
|
954
|
+
return parseJsonBody(body);
|
|
955
|
+
}
|
|
956
|
+
const streamed = await readRequestBodyBuffer(req);
|
|
957
|
+
if (!streamed || streamed.length === 0) {
|
|
958
|
+
return {};
|
|
959
|
+
}
|
|
960
|
+
return parseJsonBody(streamed);
|
|
961
|
+
}
|
|
135
962
|
function pickString(record, keys) {
|
|
136
963
|
for (const key of keys) {
|
|
137
964
|
const value = record[key];
|
|
@@ -141,6 +968,23 @@ function pickString(record, keys) {
|
|
|
141
968
|
}
|
|
142
969
|
return null;
|
|
143
970
|
}
|
|
971
|
+
function pickHeaderString(headers, keys) {
|
|
972
|
+
for (const key of keys) {
|
|
973
|
+
const candidates = [key, key.toLowerCase(), key.toUpperCase()];
|
|
974
|
+
for (const candidate of candidates) {
|
|
975
|
+
const raw = headers[candidate];
|
|
976
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
977
|
+
return raw.trim();
|
|
978
|
+
}
|
|
979
|
+
if (Array.isArray(raw)) {
|
|
980
|
+
const first = raw.find((value) => typeof value === "string" && value.trim().length > 0);
|
|
981
|
+
if (first)
|
|
982
|
+
return first.trim();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
144
988
|
function pickNumber(record, keys) {
|
|
145
989
|
for (const key of keys) {
|
|
146
990
|
const value = record[key];
|
|
@@ -207,41 +1051,1987 @@ function mapDecisionEntity(entity) {
|
|
|
207
1051
|
metadata: record,
|
|
208
1052
|
};
|
|
209
1053
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
1054
|
+
function parsePositiveInt(raw, fallback) {
|
|
1055
|
+
if (!raw)
|
|
1056
|
+
return fallback;
|
|
1057
|
+
const parsed = Number(raw);
|
|
1058
|
+
if (!Number.isFinite(parsed))
|
|
1059
|
+
return fallback;
|
|
1060
|
+
return Math.max(1, Math.floor(parsed));
|
|
1061
|
+
}
|
|
1062
|
+
function parseBooleanQuery(raw) {
|
|
1063
|
+
if (!raw)
|
|
1064
|
+
return false;
|
|
1065
|
+
const normalized = raw.trim().toLowerCase();
|
|
1066
|
+
return (normalized === "1" ||
|
|
1067
|
+
normalized === "true" ||
|
|
1068
|
+
normalized === "yes" ||
|
|
1069
|
+
normalized === "on");
|
|
1070
|
+
}
|
|
1071
|
+
const DEFAULT_DURATION_HOURS = {
|
|
1072
|
+
initiative: 40,
|
|
1073
|
+
workstream: 16,
|
|
1074
|
+
milestone: 6,
|
|
1075
|
+
task: 2,
|
|
1076
|
+
};
|
|
1077
|
+
function readBudgetEnvNumber(name, fallback, bounds = {}) {
|
|
1078
|
+
const raw = process.env[name];
|
|
1079
|
+
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
1080
|
+
return fallback;
|
|
1081
|
+
const parsed = Number(raw);
|
|
1082
|
+
if (!Number.isFinite(parsed))
|
|
1083
|
+
return fallback;
|
|
1084
|
+
if (typeof bounds.min === "number" && parsed < bounds.min)
|
|
1085
|
+
return fallback;
|
|
1086
|
+
if (typeof bounds.max === "number" && parsed > bounds.max)
|
|
1087
|
+
return fallback;
|
|
1088
|
+
return parsed;
|
|
1089
|
+
}
|
|
1090
|
+
const DEFAULT_TOKEN_MODEL_PRICING_USD_PER_1M = {
|
|
1091
|
+
// GPT-5.3 Codex API pricing is not published yet; use GPT-5.2 Codex pricing as proxy.
|
|
1092
|
+
gpt53CodexProxy: {
|
|
1093
|
+
input: readBudgetEnvNumber("ORGX_BUDGET_GPT53_CODEX_INPUT_PER_1M", 1.75, { min: 0 }),
|
|
1094
|
+
cachedInput: readBudgetEnvNumber("ORGX_BUDGET_GPT53_CODEX_CACHED_INPUT_PER_1M", 0.175, {
|
|
1095
|
+
min: 0,
|
|
1096
|
+
}),
|
|
1097
|
+
output: readBudgetEnvNumber("ORGX_BUDGET_GPT53_CODEX_OUTPUT_PER_1M", 14, { min: 0 }),
|
|
1098
|
+
},
|
|
1099
|
+
opus46: {
|
|
1100
|
+
input: readBudgetEnvNumber("ORGX_BUDGET_OPUS46_INPUT_PER_1M", 5, { min: 0 }),
|
|
1101
|
+
// Anthropic does not publish a fixed cached-input rate on the model page.
|
|
1102
|
+
cachedInput: readBudgetEnvNumber("ORGX_BUDGET_OPUS46_CACHED_INPUT_PER_1M", 5, { min: 0 }),
|
|
1103
|
+
output: readBudgetEnvNumber("ORGX_BUDGET_OPUS46_OUTPUT_PER_1M", 25, { min: 0 }),
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
const DEFAULT_TOKEN_BUDGET_ASSUMPTIONS = {
|
|
1107
|
+
tokensPerHour: readBudgetEnvNumber("ORGX_BUDGET_TOKENS_PER_HOUR", 1_200_000, { min: 1 }),
|
|
1108
|
+
inputShare: readBudgetEnvNumber("ORGX_BUDGET_INPUT_TOKEN_SHARE", 0.86, { min: 0, max: 1 }),
|
|
1109
|
+
cachedInputShare: readBudgetEnvNumber("ORGX_BUDGET_CACHED_INPUT_SHARE", 0.15, {
|
|
1110
|
+
min: 0,
|
|
1111
|
+
max: 1,
|
|
1112
|
+
}),
|
|
1113
|
+
contingencyMultiplier: readBudgetEnvNumber("ORGX_BUDGET_CONTINGENCY_MULTIPLIER", 1.3, {
|
|
1114
|
+
min: 0.1,
|
|
1115
|
+
}),
|
|
1116
|
+
roundingStepUsd: readBudgetEnvNumber("ORGX_BUDGET_ROUNDING_STEP_USD", 5, { min: 0.01 }),
|
|
1117
|
+
};
|
|
1118
|
+
const DEFAULT_TOKEN_MODEL_MIX = {
|
|
1119
|
+
gpt53CodexProxy: 0.7,
|
|
1120
|
+
opus46: 0.3,
|
|
1121
|
+
};
|
|
1122
|
+
function modelCostPerMillionTokensUsd(pricing) {
|
|
1123
|
+
const inputShare = DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.inputShare;
|
|
1124
|
+
const outputShare = Math.max(0, 1 - inputShare);
|
|
1125
|
+
const cachedShare = DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.cachedInputShare;
|
|
1126
|
+
const uncachedShare = Math.max(0, 1 - cachedShare);
|
|
1127
|
+
const effectiveInputRate = pricing.input * uncachedShare + pricing.cachedInput * cachedShare;
|
|
1128
|
+
return inputShare * effectiveInputRate + outputShare * pricing.output;
|
|
1129
|
+
}
|
|
1130
|
+
function estimateBudgetUsdFromDurationHours(durationHours) {
|
|
1131
|
+
if (!Number.isFinite(durationHours) || durationHours <= 0)
|
|
1132
|
+
return 0;
|
|
1133
|
+
const blendedPerMillionUsd = DEFAULT_TOKEN_MODEL_MIX.gpt53CodexProxy *
|
|
1134
|
+
modelCostPerMillionTokensUsd(DEFAULT_TOKEN_MODEL_PRICING_USD_PER_1M.gpt53CodexProxy) +
|
|
1135
|
+
DEFAULT_TOKEN_MODEL_MIX.opus46 *
|
|
1136
|
+
modelCostPerMillionTokensUsd(DEFAULT_TOKEN_MODEL_PRICING_USD_PER_1M.opus46);
|
|
1137
|
+
const tokenMillions = (durationHours * DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.tokensPerHour) / 1_000_000;
|
|
1138
|
+
const rawBudgetUsd = tokenMillions *
|
|
1139
|
+
blendedPerMillionUsd *
|
|
1140
|
+
DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.contingencyMultiplier;
|
|
1141
|
+
const roundedBudgetUsd = Math.round(rawBudgetUsd / DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.roundingStepUsd) *
|
|
1142
|
+
DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.roundingStepUsd;
|
|
1143
|
+
return Math.max(0, roundedBudgetUsd);
|
|
1144
|
+
}
|
|
1145
|
+
function isLegacyHourlyBudget(budgetUsd, durationHours) {
|
|
1146
|
+
if (!Number.isFinite(budgetUsd) || !Number.isFinite(durationHours) || durationHours <= 0) {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
const legacyHourlyBudget = durationHours * 40;
|
|
1150
|
+
return Math.abs(budgetUsd - legacyHourlyBudget) <= 0.5;
|
|
1151
|
+
}
|
|
1152
|
+
const DEFAULT_BUDGET_USD = {
|
|
1153
|
+
initiative: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.initiative),
|
|
1154
|
+
workstream: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.workstream),
|
|
1155
|
+
milestone: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.milestone),
|
|
1156
|
+
task: estimateBudgetUsdFromDurationHours(DEFAULT_DURATION_HOURS.task),
|
|
1157
|
+
};
|
|
1158
|
+
const PRIORITY_LABEL_TO_NUM = {
|
|
1159
|
+
urgent: 10,
|
|
1160
|
+
high: 25,
|
|
1161
|
+
medium: 50,
|
|
1162
|
+
low: 75,
|
|
1163
|
+
};
|
|
1164
|
+
function clampPriority(value) {
|
|
1165
|
+
if (!Number.isFinite(value))
|
|
1166
|
+
return 60;
|
|
1167
|
+
return Math.max(1, Math.min(100, Math.round(value)));
|
|
1168
|
+
}
|
|
1169
|
+
function mapPriorityNumToLabel(priorityNum) {
|
|
1170
|
+
if (priorityNum <= 12)
|
|
1171
|
+
return "urgent";
|
|
1172
|
+
if (priorityNum <= 30)
|
|
1173
|
+
return "high";
|
|
1174
|
+
if (priorityNum <= 60)
|
|
1175
|
+
return "medium";
|
|
1176
|
+
return "low";
|
|
1177
|
+
}
|
|
1178
|
+
function getRecordMetadata(record) {
|
|
1179
|
+
const metadata = record.metadata;
|
|
1180
|
+
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
|
1181
|
+
return metadata;
|
|
1182
|
+
}
|
|
1183
|
+
return {};
|
|
1184
|
+
}
|
|
1185
|
+
function extractBudgetUsdFromText(...texts) {
|
|
1186
|
+
for (const text of texts) {
|
|
1187
|
+
if (typeof text !== "string" || text.trim().length === 0)
|
|
1188
|
+
continue;
|
|
1189
|
+
const moneyMatch = /(?:expected\s+budget|budget)[^0-9$]{0,24}\$?\s*([0-9][0-9,]*(?:\.[0-9]{1,2})?)/i.exec(text);
|
|
1190
|
+
if (!moneyMatch)
|
|
1191
|
+
continue;
|
|
1192
|
+
const numeric = Number(moneyMatch[1].replace(/,/g, ""));
|
|
1193
|
+
if (Number.isFinite(numeric) && numeric >= 0)
|
|
1194
|
+
return numeric;
|
|
1195
|
+
}
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
function extractDurationHoursFromText(...texts) {
|
|
1199
|
+
for (const text of texts) {
|
|
1200
|
+
if (typeof text !== "string" || text.trim().length === 0)
|
|
1201
|
+
continue;
|
|
1202
|
+
const durationMatch = /(?:expected\s+duration|duration)[^0-9]{0,24}([0-9]+(?:\.[0-9]+)?)\s*h/i.exec(text);
|
|
1203
|
+
if (!durationMatch)
|
|
1204
|
+
continue;
|
|
1205
|
+
const numeric = Number(durationMatch[1]);
|
|
1206
|
+
if (Number.isFinite(numeric) && numeric >= 0)
|
|
1207
|
+
return numeric;
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
function pickStringArray(record, keys) {
|
|
1212
|
+
for (const key of keys) {
|
|
1213
|
+
const value = record[key];
|
|
1214
|
+
if (Array.isArray(value)) {
|
|
1215
|
+
const items = value
|
|
1216
|
+
.filter((entry) => typeof entry === "string")
|
|
1217
|
+
.map((entry) => entry.trim())
|
|
1218
|
+
.filter(Boolean);
|
|
1219
|
+
if (items.length > 0)
|
|
1220
|
+
return items;
|
|
225
1221
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
1222
|
+
if (typeof value === "string") {
|
|
1223
|
+
const items = value
|
|
1224
|
+
.split(",")
|
|
1225
|
+
.map((entry) => entry.trim())
|
|
1226
|
+
.filter(Boolean);
|
|
1227
|
+
if (items.length > 0)
|
|
1228
|
+
return items;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return [];
|
|
1232
|
+
}
|
|
1233
|
+
function dedupeStrings(items) {
|
|
1234
|
+
const seen = new Set();
|
|
1235
|
+
const out = [];
|
|
1236
|
+
for (const item of items) {
|
|
1237
|
+
if (!item || seen.has(item))
|
|
1238
|
+
continue;
|
|
1239
|
+
seen.add(item);
|
|
1240
|
+
out.push(item);
|
|
1241
|
+
}
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
function normalizePriorityForEntity(record) {
|
|
1245
|
+
const explicitPriorityNum = pickNumber(record, [
|
|
1246
|
+
"priority_num",
|
|
1247
|
+
"priorityNum",
|
|
1248
|
+
"priority_number",
|
|
1249
|
+
]);
|
|
1250
|
+
const priorityLabelRaw = pickString(record, ["priority", "priority_label"]);
|
|
1251
|
+
if (explicitPriorityNum !== null) {
|
|
1252
|
+
const clamped = clampPriority(explicitPriorityNum);
|
|
1253
|
+
return {
|
|
1254
|
+
priorityNum: clamped,
|
|
1255
|
+
priorityLabel: priorityLabelRaw ?? mapPriorityNumToLabel(clamped),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
if (priorityLabelRaw) {
|
|
1259
|
+
const mapped = PRIORITY_LABEL_TO_NUM[priorityLabelRaw.toLowerCase()] ?? 60;
|
|
1260
|
+
return {
|
|
1261
|
+
priorityNum: mapped,
|
|
1262
|
+
priorityLabel: priorityLabelRaw.toLowerCase(),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
priorityNum: 60,
|
|
1267
|
+
priorityLabel: null,
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function normalizeDependencies(record) {
|
|
1271
|
+
const metadata = getRecordMetadata(record);
|
|
1272
|
+
const direct = pickStringArray(record, [
|
|
1273
|
+
"depends_on",
|
|
1274
|
+
"dependsOn",
|
|
1275
|
+
"dependency_ids",
|
|
1276
|
+
"dependencyIds",
|
|
1277
|
+
"dependencies",
|
|
1278
|
+
]);
|
|
1279
|
+
const nested = pickStringArray(metadata, [
|
|
1280
|
+
"depends_on",
|
|
1281
|
+
"dependsOn",
|
|
1282
|
+
"dependency_ids",
|
|
1283
|
+
"dependencyIds",
|
|
1284
|
+
"dependencies",
|
|
1285
|
+
]);
|
|
1286
|
+
return dedupeStrings([...direct, ...nested]);
|
|
1287
|
+
}
|
|
1288
|
+
function normalizeAssignedAgents(record) {
|
|
1289
|
+
const metadata = getRecordMetadata(record);
|
|
1290
|
+
const ids = dedupeStrings([
|
|
1291
|
+
...pickStringArray(record, ["assigned_agent_ids", "assignedAgentIds"]),
|
|
1292
|
+
...pickStringArray(metadata, ["assigned_agent_ids", "assignedAgentIds"]),
|
|
1293
|
+
]);
|
|
1294
|
+
const names = dedupeStrings([
|
|
1295
|
+
...pickStringArray(record, ["assigned_agent_names", "assignedAgentNames"]),
|
|
1296
|
+
...pickStringArray(metadata, ["assigned_agent_names", "assignedAgentNames"]),
|
|
1297
|
+
]);
|
|
1298
|
+
const objectCandidates = [
|
|
1299
|
+
record.assigned_agents,
|
|
1300
|
+
record.assignedAgents,
|
|
1301
|
+
metadata.assigned_agents,
|
|
1302
|
+
metadata.assignedAgents,
|
|
1303
|
+
];
|
|
1304
|
+
const fromObjects = [];
|
|
1305
|
+
for (const candidate of objectCandidates) {
|
|
1306
|
+
if (!Array.isArray(candidate))
|
|
1307
|
+
continue;
|
|
1308
|
+
for (const entry of candidate) {
|
|
1309
|
+
if (!entry || typeof entry !== "object")
|
|
1310
|
+
continue;
|
|
1311
|
+
const item = entry;
|
|
1312
|
+
const id = pickString(item, ["id", "agent_id", "agentId"]) ?? "";
|
|
1313
|
+
const name = pickString(item, ["name", "agent_name", "agentName"]) ?? id;
|
|
1314
|
+
if (!name)
|
|
1315
|
+
continue;
|
|
1316
|
+
fromObjects.push({
|
|
1317
|
+
id: id || `name:${name}`,
|
|
1318
|
+
name,
|
|
1319
|
+
domain: pickString(item, ["domain", "role"]),
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
const merged = [...fromObjects];
|
|
1324
|
+
if (merged.length === 0 && (names.length > 0 || ids.length > 0)) {
|
|
1325
|
+
const maxLen = Math.max(names.length, ids.length);
|
|
1326
|
+
for (let i = 0; i < maxLen; i += 1) {
|
|
1327
|
+
const id = ids[i] ?? `name:${names[i] ?? `agent-${i + 1}`}`;
|
|
1328
|
+
const name = names[i] ?? ids[i] ?? `Agent ${i + 1}`;
|
|
1329
|
+
merged.push({ id, name, domain: null });
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const seen = new Set();
|
|
1333
|
+
const deduped = [];
|
|
1334
|
+
for (const item of merged) {
|
|
1335
|
+
const key = `${item.id}:${item.name}`.toLowerCase();
|
|
1336
|
+
if (seen.has(key))
|
|
1337
|
+
continue;
|
|
1338
|
+
seen.add(key);
|
|
1339
|
+
deduped.push(item);
|
|
1340
|
+
}
|
|
1341
|
+
return deduped;
|
|
1342
|
+
}
|
|
1343
|
+
function toMissionControlNode(type, entity, fallbackInitiativeId) {
|
|
1344
|
+
const record = entity;
|
|
1345
|
+
const metadata = getRecordMetadata(record);
|
|
1346
|
+
const initiativeId = pickString(record, ["initiative_id", "initiativeId"]) ??
|
|
1347
|
+
pickString(metadata, ["initiative_id", "initiativeId"]) ??
|
|
1348
|
+
(type === "initiative" ? String(record.id ?? fallbackInitiativeId) : fallbackInitiativeId);
|
|
1349
|
+
const workstreamId = type === "workstream"
|
|
1350
|
+
? String(record.id ?? "")
|
|
1351
|
+
: pickString(record, ["workstream_id", "workstreamId"]) ??
|
|
1352
|
+
pickString(metadata, ["workstream_id", "workstreamId"]);
|
|
1353
|
+
const milestoneId = type === "milestone"
|
|
1354
|
+
? String(record.id ?? "")
|
|
1355
|
+
: pickString(record, ["milestone_id", "milestoneId"]) ??
|
|
1356
|
+
pickString(metadata, ["milestone_id", "milestoneId"]);
|
|
1357
|
+
const parentIdRaw = pickString(record, ["parentId", "parent_id"]) ??
|
|
1358
|
+
pickString(metadata, ["parentId", "parent_id"]);
|
|
1359
|
+
const parentId = parentIdRaw ??
|
|
1360
|
+
(type === "initiative"
|
|
1361
|
+
? null
|
|
1362
|
+
: type === "workstream"
|
|
1363
|
+
? initiativeId
|
|
1364
|
+
: type === "milestone"
|
|
1365
|
+
? workstreamId ?? initiativeId
|
|
1366
|
+
: milestoneId ?? workstreamId ?? initiativeId);
|
|
1367
|
+
const status = pickString(record, ["status"]) ??
|
|
1368
|
+
(type === "task" ? "todo" : "planned");
|
|
1369
|
+
const dueDate = toIsoString(pickString(record, ["due_date", "dueDate", "target_date", "targetDate"]));
|
|
1370
|
+
const etaEndAt = toIsoString(pickString(record, ["eta_end_at", "etaEndAt"]));
|
|
1371
|
+
const expectedDuration = pickNumber(record, [
|
|
1372
|
+
"expected_duration_hours",
|
|
1373
|
+
"expectedDurationHours",
|
|
1374
|
+
"duration_hours",
|
|
1375
|
+
"durationHours",
|
|
1376
|
+
]) ??
|
|
1377
|
+
pickNumber(metadata, [
|
|
1378
|
+
"expected_duration_hours",
|
|
1379
|
+
"expectedDurationHours",
|
|
1380
|
+
"duration_hours",
|
|
1381
|
+
"durationHours",
|
|
1382
|
+
]) ??
|
|
1383
|
+
extractDurationHoursFromText(pickString(record, ["description", "summary", "context"]), pickString(metadata, ["description", "summary", "context"])) ??
|
|
1384
|
+
DEFAULT_DURATION_HOURS[type];
|
|
1385
|
+
const explicitBudget = pickNumber(record, [
|
|
1386
|
+
"expected_budget_usd",
|
|
1387
|
+
"expectedBudgetUsd",
|
|
1388
|
+
"budget_usd",
|
|
1389
|
+
"budgetUsd",
|
|
1390
|
+
]) ??
|
|
1391
|
+
pickNumber(metadata, [
|
|
1392
|
+
"expected_budget_usd",
|
|
1393
|
+
"expectedBudgetUsd",
|
|
1394
|
+
"budget_usd",
|
|
1395
|
+
"budgetUsd",
|
|
1396
|
+
]);
|
|
1397
|
+
const extractedBudget = extractBudgetUsdFromText(pickString(record, ["description", "summary", "context"]), pickString(metadata, ["description", "summary", "context"])) ?? null;
|
|
1398
|
+
const tokenModeledBudget = estimateBudgetUsdFromDurationHours(expectedDuration > 0 ? expectedDuration : DEFAULT_DURATION_HOURS[type]) || DEFAULT_BUDGET_USD[type];
|
|
1399
|
+
const expectedBudget = explicitBudget ??
|
|
1400
|
+
(typeof extractedBudget === "number"
|
|
1401
|
+
? isLegacyHourlyBudget(extractedBudget, expectedDuration)
|
|
1402
|
+
? tokenModeledBudget
|
|
1403
|
+
: extractedBudget
|
|
1404
|
+
: DEFAULT_BUDGET_USD[type]);
|
|
1405
|
+
const priority = normalizePriorityForEntity(record);
|
|
1406
|
+
return {
|
|
1407
|
+
id: String(record.id ?? ""),
|
|
1408
|
+
type,
|
|
1409
|
+
title: pickString(record, ["title", "name"]) ??
|
|
1410
|
+
`${type[0].toUpperCase()}${type.slice(1)} ${String(record.id ?? "")}`,
|
|
1411
|
+
status,
|
|
1412
|
+
parentId: parentId ?? null,
|
|
1413
|
+
initiativeId: initiativeId ?? null,
|
|
1414
|
+
workstreamId: workstreamId ?? null,
|
|
1415
|
+
milestoneId: milestoneId ?? null,
|
|
1416
|
+
priorityNum: priority.priorityNum,
|
|
1417
|
+
priorityLabel: priority.priorityLabel,
|
|
1418
|
+
dependencyIds: normalizeDependencies(record),
|
|
1419
|
+
dueDate,
|
|
1420
|
+
etaEndAt,
|
|
1421
|
+
expectedDurationHours: expectedDuration > 0 ? expectedDuration : DEFAULT_DURATION_HOURS[type],
|
|
1422
|
+
expectedBudgetUsd: expectedBudget >= 0 ? expectedBudget : DEFAULT_BUDGET_USD[type],
|
|
1423
|
+
assignedAgents: normalizeAssignedAgents(record),
|
|
1424
|
+
updatedAt: toIsoString(pickString(record, [
|
|
1425
|
+
"updated_at",
|
|
1426
|
+
"updatedAt",
|
|
1427
|
+
"created_at",
|
|
1428
|
+
"createdAt",
|
|
1429
|
+
])),
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
function isTodoStatus(status) {
|
|
1433
|
+
const normalized = status.toLowerCase();
|
|
1434
|
+
return (normalized === "todo" ||
|
|
1435
|
+
normalized === "not_started" ||
|
|
1436
|
+
normalized === "planned" ||
|
|
1437
|
+
normalized === "backlog" ||
|
|
1438
|
+
normalized === "pending");
|
|
1439
|
+
}
|
|
1440
|
+
function isInProgressStatus(status) {
|
|
1441
|
+
const normalized = status.toLowerCase();
|
|
1442
|
+
return (normalized === "in_progress" ||
|
|
1443
|
+
normalized === "active" ||
|
|
1444
|
+
normalized === "running" ||
|
|
1445
|
+
normalized === "queued");
|
|
1446
|
+
}
|
|
1447
|
+
function isDoneStatus(status) {
|
|
1448
|
+
const normalized = status.toLowerCase();
|
|
1449
|
+
return (normalized === "done" ||
|
|
1450
|
+
normalized === "completed" ||
|
|
1451
|
+
normalized === "cancelled" ||
|
|
1452
|
+
normalized === "archived" ||
|
|
1453
|
+
normalized === "deleted");
|
|
1454
|
+
}
|
|
1455
|
+
function detectCycleEdgeKeys(edges) {
|
|
1456
|
+
const adjacency = new Map();
|
|
1457
|
+
for (const edge of edges) {
|
|
1458
|
+
const list = adjacency.get(edge.from) ?? [];
|
|
1459
|
+
list.push(edge.to);
|
|
1460
|
+
adjacency.set(edge.from, list);
|
|
1461
|
+
}
|
|
1462
|
+
const visiting = new Set();
|
|
1463
|
+
const visited = new Set();
|
|
1464
|
+
const cycleEdgeKeys = new Set();
|
|
1465
|
+
function dfs(nodeId) {
|
|
1466
|
+
if (visited.has(nodeId))
|
|
1467
|
+
return;
|
|
1468
|
+
visiting.add(nodeId);
|
|
1469
|
+
const next = adjacency.get(nodeId) ?? [];
|
|
1470
|
+
for (const childId of next) {
|
|
1471
|
+
if (visiting.has(childId)) {
|
|
1472
|
+
cycleEdgeKeys.add(`${nodeId}->${childId}`);
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
dfs(childId);
|
|
1476
|
+
}
|
|
1477
|
+
visiting.delete(nodeId);
|
|
1478
|
+
visited.add(nodeId);
|
|
1479
|
+
}
|
|
1480
|
+
for (const key of adjacency.keys()) {
|
|
1481
|
+
if (!visited.has(key))
|
|
1482
|
+
dfs(key);
|
|
1483
|
+
}
|
|
1484
|
+
return cycleEdgeKeys;
|
|
1485
|
+
}
|
|
1486
|
+
async function listEntitiesSafe(client, type, filters) {
|
|
1487
|
+
try {
|
|
1488
|
+
const response = await client.listEntities(type, filters);
|
|
1489
|
+
const items = Array.isArray(response.data) ? response.data : [];
|
|
1490
|
+
return { items, warning: null };
|
|
1491
|
+
}
|
|
1492
|
+
catch (err) {
|
|
1493
|
+
return {
|
|
1494
|
+
items: [],
|
|
1495
|
+
warning: `${type} unavailable (${safeErrorMessage(err)})`,
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
async function buildMissionControlGraph(client, initiativeId) {
|
|
1500
|
+
const degraded = [];
|
|
1501
|
+
const [initiativeResult, workstreamResult, milestoneResult, taskResult] = await Promise.all([
|
|
1502
|
+
listEntitiesSafe(client, "initiative", { limit: 300 }),
|
|
1503
|
+
listEntitiesSafe(client, "workstream", {
|
|
1504
|
+
initiative_id: initiativeId,
|
|
1505
|
+
limit: 500,
|
|
1506
|
+
}),
|
|
1507
|
+
listEntitiesSafe(client, "milestone", {
|
|
1508
|
+
initiative_id: initiativeId,
|
|
1509
|
+
limit: 700,
|
|
1510
|
+
}),
|
|
1511
|
+
listEntitiesSafe(client, "task", {
|
|
1512
|
+
initiative_id: initiativeId,
|
|
1513
|
+
limit: 1200,
|
|
1514
|
+
}),
|
|
1515
|
+
]);
|
|
1516
|
+
for (const warning of [
|
|
1517
|
+
initiativeResult.warning,
|
|
1518
|
+
workstreamResult.warning,
|
|
1519
|
+
milestoneResult.warning,
|
|
1520
|
+
taskResult.warning,
|
|
1521
|
+
]) {
|
|
1522
|
+
if (warning)
|
|
1523
|
+
degraded.push(warning);
|
|
1524
|
+
}
|
|
1525
|
+
const initiativeEntity = initiativeResult.items.find((item) => String(item.id ?? "") === initiativeId);
|
|
1526
|
+
const initiativeNode = initiativeEntity
|
|
1527
|
+
? toMissionControlNode("initiative", initiativeEntity, initiativeId)
|
|
1528
|
+
: {
|
|
1529
|
+
id: initiativeId,
|
|
1530
|
+
type: "initiative",
|
|
1531
|
+
title: `Initiative ${initiativeId.slice(0, 8)}`,
|
|
1532
|
+
status: "active",
|
|
1533
|
+
parentId: null,
|
|
1534
|
+
initiativeId,
|
|
1535
|
+
workstreamId: null,
|
|
1536
|
+
milestoneId: null,
|
|
1537
|
+
priorityNum: 60,
|
|
1538
|
+
priorityLabel: null,
|
|
1539
|
+
dependencyIds: [],
|
|
1540
|
+
dueDate: null,
|
|
1541
|
+
etaEndAt: null,
|
|
1542
|
+
expectedDurationHours: DEFAULT_DURATION_HOURS.initiative,
|
|
1543
|
+
expectedBudgetUsd: DEFAULT_BUDGET_USD.initiative,
|
|
1544
|
+
assignedAgents: [],
|
|
1545
|
+
updatedAt: null,
|
|
1546
|
+
};
|
|
1547
|
+
const workstreamNodes = workstreamResult.items.map((item) => toMissionControlNode("workstream", item, initiativeId));
|
|
1548
|
+
const milestoneNodes = milestoneResult.items.map((item) => toMissionControlNode("milestone", item, initiativeId));
|
|
1549
|
+
const taskNodes = taskResult.items.map((item) => toMissionControlNode("task", item, initiativeId));
|
|
1550
|
+
const nodes = [
|
|
1551
|
+
initiativeNode,
|
|
1552
|
+
...workstreamNodes,
|
|
1553
|
+
...milestoneNodes,
|
|
1554
|
+
...taskNodes,
|
|
1555
|
+
];
|
|
1556
|
+
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
|
1557
|
+
for (const node of nodes) {
|
|
1558
|
+
const validDependencies = dedupeStrings(node.dependencyIds.filter((depId) => depId !== node.id && nodeMap.has(depId)));
|
|
1559
|
+
node.dependencyIds = validDependencies;
|
|
1560
|
+
}
|
|
1561
|
+
let edges = [];
|
|
1562
|
+
for (const node of nodes) {
|
|
1563
|
+
if (node.type === "initiative")
|
|
1564
|
+
continue;
|
|
1565
|
+
for (const depId of node.dependencyIds) {
|
|
1566
|
+
edges.push({
|
|
1567
|
+
from: depId,
|
|
1568
|
+
to: node.id,
|
|
1569
|
+
kind: "depends_on",
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
edges = edges.filter((edge, index, arr) => arr.findIndex((candidate) => candidate.from === edge.from &&
|
|
1574
|
+
candidate.to === edge.to &&
|
|
1575
|
+
candidate.kind === edge.kind) === index);
|
|
1576
|
+
const cyclicEdgeKeys = detectCycleEdgeKeys(edges);
|
|
1577
|
+
if (cyclicEdgeKeys.size > 0) {
|
|
1578
|
+
degraded.push(`Detected ${cyclicEdgeKeys.size} cyclic dependency edge(s); excluded from ETA graph.`);
|
|
1579
|
+
edges = edges.filter((edge) => !cyclicEdgeKeys.has(`${edge.from}->${edge.to}`));
|
|
1580
|
+
for (const node of nodes) {
|
|
1581
|
+
node.dependencyIds = node.dependencyIds.filter((depId) => !cyclicEdgeKeys.has(`${depId}->${node.id}`));
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const etaMemo = new Map();
|
|
1585
|
+
const etaVisiting = new Set();
|
|
1586
|
+
const computeEtaEpoch = (nodeId) => {
|
|
1587
|
+
const node = nodeMap.get(nodeId);
|
|
1588
|
+
if (!node)
|
|
1589
|
+
return Date.now();
|
|
1590
|
+
const cached = etaMemo.get(nodeId);
|
|
1591
|
+
if (cached !== undefined)
|
|
1592
|
+
return cached;
|
|
1593
|
+
const parsedEtaOverride = node.etaEndAt ? Date.parse(node.etaEndAt) : Number.NaN;
|
|
1594
|
+
if (Number.isFinite(parsedEtaOverride)) {
|
|
1595
|
+
etaMemo.set(nodeId, parsedEtaOverride);
|
|
1596
|
+
return parsedEtaOverride;
|
|
1597
|
+
}
|
|
1598
|
+
const parsedDueDate = node.dueDate ? Date.parse(node.dueDate) : Number.NaN;
|
|
1599
|
+
if (Number.isFinite(parsedDueDate)) {
|
|
1600
|
+
etaMemo.set(nodeId, parsedDueDate);
|
|
1601
|
+
return parsedDueDate;
|
|
1602
|
+
}
|
|
1603
|
+
if (etaVisiting.has(nodeId)) {
|
|
1604
|
+
degraded.push(`ETA cycle fallback on node ${nodeId}.`);
|
|
1605
|
+
const fallback = Date.now();
|
|
1606
|
+
etaMemo.set(nodeId, fallback);
|
|
1607
|
+
return fallback;
|
|
1608
|
+
}
|
|
1609
|
+
etaVisiting.add(nodeId);
|
|
1610
|
+
let dependencyMax = 0;
|
|
1611
|
+
for (const depId of node.dependencyIds) {
|
|
1612
|
+
dependencyMax = Math.max(dependencyMax, computeEtaEpoch(depId));
|
|
1613
|
+
}
|
|
1614
|
+
etaVisiting.delete(nodeId);
|
|
1615
|
+
const durationMs = (node.expectedDurationHours > 0
|
|
1616
|
+
? node.expectedDurationHours
|
|
1617
|
+
: DEFAULT_DURATION_HOURS[node.type]) * 60 * 60 * 1000;
|
|
1618
|
+
const eta = Math.max(Date.now(), dependencyMax) + durationMs;
|
|
1619
|
+
etaMemo.set(nodeId, eta);
|
|
1620
|
+
return eta;
|
|
1621
|
+
};
|
|
1622
|
+
for (const node of nodes) {
|
|
1623
|
+
const eta = computeEtaEpoch(node.id);
|
|
1624
|
+
if (Number.isFinite(eta)) {
|
|
1625
|
+
node.etaEndAt = new Date(eta).toISOString();
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const taskNodesOnly = nodes.filter((node) => node.type === "task");
|
|
1629
|
+
const hasActiveTasks = taskNodesOnly.some((node) => isInProgressStatus(node.status));
|
|
1630
|
+
const hasTodoTasks = taskNodesOnly.some((node) => isTodoStatus(node.status));
|
|
1631
|
+
if (initiativeNode.status.toLowerCase() === "active" &&
|
|
1632
|
+
!hasActiveTasks &&
|
|
1633
|
+
hasTodoTasks) {
|
|
1634
|
+
initiativeNode.status = "paused";
|
|
1635
|
+
}
|
|
1636
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
1637
|
+
const taskIsReady = (task) => task.dependencyIds.every((depId) => {
|
|
1638
|
+
const dependency = nodeById.get(depId);
|
|
1639
|
+
return dependency ? isDoneStatus(dependency.status) : true;
|
|
1640
|
+
});
|
|
1641
|
+
const taskHasBlockedParent = (task) => {
|
|
1642
|
+
const milestone = task.milestoneId ? nodeById.get(task.milestoneId) ?? null : null;
|
|
1643
|
+
const workstream = task.workstreamId ? nodeById.get(task.workstreamId) ?? null : null;
|
|
1644
|
+
return (milestone?.status?.toLowerCase() === "blocked" ||
|
|
1645
|
+
workstream?.status?.toLowerCase() === "blocked");
|
|
1646
|
+
};
|
|
1647
|
+
const recentTodos = nodes
|
|
1648
|
+
.filter((node) => node.type === "task" && isTodoStatus(node.status))
|
|
1649
|
+
.sort((a, b) => {
|
|
1650
|
+
const aReady = taskIsReady(a);
|
|
1651
|
+
const bReady = taskIsReady(b);
|
|
1652
|
+
if (aReady !== bReady)
|
|
1653
|
+
return aReady ? -1 : 1;
|
|
1654
|
+
const aBlocked = taskHasBlockedParent(a);
|
|
1655
|
+
const bBlocked = taskHasBlockedParent(b);
|
|
1656
|
+
if (aBlocked !== bBlocked)
|
|
1657
|
+
return aBlocked ? 1 : -1;
|
|
1658
|
+
const priorityDelta = a.priorityNum - b.priorityNum;
|
|
1659
|
+
if (priorityDelta !== 0)
|
|
1660
|
+
return priorityDelta;
|
|
1661
|
+
const aDue = a.dueDate ? Date.parse(a.dueDate) : Number.POSITIVE_INFINITY;
|
|
1662
|
+
const bDue = b.dueDate ? Date.parse(b.dueDate) : Number.POSITIVE_INFINITY;
|
|
1663
|
+
if (aDue !== bDue)
|
|
1664
|
+
return aDue - bDue;
|
|
1665
|
+
const aEta = a.etaEndAt ? Date.parse(a.etaEndAt) : Number.POSITIVE_INFINITY;
|
|
1666
|
+
const bEta = b.etaEndAt ? Date.parse(b.etaEndAt) : Number.POSITIVE_INFINITY;
|
|
1667
|
+
if (aEta !== bEta)
|
|
1668
|
+
return aEta - bEta;
|
|
1669
|
+
const aEpoch = a.updatedAt ? Date.parse(a.updatedAt) : 0;
|
|
1670
|
+
const bEpoch = b.updatedAt ? Date.parse(b.updatedAt) : 0;
|
|
1671
|
+
return aEpoch - bEpoch;
|
|
1672
|
+
})
|
|
1673
|
+
.map((node) => node.id);
|
|
1674
|
+
return {
|
|
1675
|
+
initiative: {
|
|
1676
|
+
id: initiativeNode.id,
|
|
1677
|
+
title: initiativeNode.title,
|
|
1678
|
+
status: initiativeNode.status,
|
|
1679
|
+
summary: initiativeEntity
|
|
1680
|
+
? pickString(initiativeEntity, [
|
|
1681
|
+
"summary",
|
|
1682
|
+
"description",
|
|
1683
|
+
"context",
|
|
1684
|
+
])
|
|
1685
|
+
: null,
|
|
1686
|
+
assignedAgents: initiativeNode.assignedAgents,
|
|
1687
|
+
},
|
|
1688
|
+
nodes,
|
|
1689
|
+
edges,
|
|
1690
|
+
recentTodos,
|
|
1691
|
+
degraded,
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
function normalizeEntityMutationPayload(payload) {
|
|
1695
|
+
const next = { ...payload };
|
|
1696
|
+
const priorityNumRaw = pickNumber(next, ["priority_num", "priorityNum"]);
|
|
1697
|
+
const priorityLabelRaw = pickString(next, ["priority", "priority_label"]);
|
|
1698
|
+
if (priorityNumRaw !== null) {
|
|
1699
|
+
const clamped = clampPriority(priorityNumRaw);
|
|
1700
|
+
next.priority_num = clamped;
|
|
1701
|
+
if (!priorityLabelRaw) {
|
|
1702
|
+
next.priority = mapPriorityNumToLabel(clamped);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
else if (priorityLabelRaw) {
|
|
1706
|
+
next.priority_num = PRIORITY_LABEL_TO_NUM[priorityLabelRaw.toLowerCase()] ?? 60;
|
|
1707
|
+
next.priority = priorityLabelRaw.toLowerCase();
|
|
1708
|
+
}
|
|
1709
|
+
const dependsOnArray = pickStringArray(next, ["depends_on", "dependsOn", "dependencies"]);
|
|
1710
|
+
if (dependsOnArray.length > 0) {
|
|
1711
|
+
next.depends_on = dedupeStrings(dependsOnArray);
|
|
1712
|
+
}
|
|
1713
|
+
else if ("depends_on" in next) {
|
|
1714
|
+
next.depends_on = [];
|
|
1715
|
+
}
|
|
1716
|
+
const expectedDuration = pickNumber(next, [
|
|
1717
|
+
"expected_duration_hours",
|
|
1718
|
+
"expectedDurationHours",
|
|
1719
|
+
]);
|
|
1720
|
+
if (expectedDuration !== null) {
|
|
1721
|
+
next.expected_duration_hours = Math.max(0, expectedDuration);
|
|
1722
|
+
}
|
|
1723
|
+
const expectedBudget = pickNumber(next, [
|
|
1724
|
+
"expected_budget_usd",
|
|
1725
|
+
"expectedBudgetUsd",
|
|
1726
|
+
"budget_usd",
|
|
1727
|
+
"budgetUsd",
|
|
1728
|
+
]);
|
|
1729
|
+
if (expectedBudget !== null) {
|
|
1730
|
+
next.expected_budget_usd = Math.max(0, expectedBudget);
|
|
1731
|
+
}
|
|
1732
|
+
const etaEndAt = pickString(next, ["eta_end_at", "etaEndAt"]);
|
|
1733
|
+
if (etaEndAt !== null) {
|
|
1734
|
+
next.eta_end_at = toIsoString(etaEndAt) ?? null;
|
|
1735
|
+
}
|
|
1736
|
+
const assignedIds = pickStringArray(next, [
|
|
1737
|
+
"assigned_agent_ids",
|
|
1738
|
+
"assignedAgentIds",
|
|
1739
|
+
]);
|
|
1740
|
+
const assignedNames = pickStringArray(next, [
|
|
1741
|
+
"assigned_agent_names",
|
|
1742
|
+
"assignedAgentNames",
|
|
1743
|
+
]);
|
|
1744
|
+
if (assignedIds.length > 0) {
|
|
1745
|
+
next.assigned_agent_ids = dedupeStrings(assignedIds);
|
|
1746
|
+
}
|
|
1747
|
+
if (assignedNames.length > 0) {
|
|
1748
|
+
next.assigned_agent_names = dedupeStrings(assignedNames);
|
|
1749
|
+
}
|
|
1750
|
+
return next;
|
|
1751
|
+
}
|
|
1752
|
+
async function resolveAutoAssignments(input) {
|
|
1753
|
+
const warnings = [];
|
|
1754
|
+
const assignedById = new Map();
|
|
1755
|
+
const addAgent = (agent) => {
|
|
1756
|
+
const key = agent.id || `name:${agent.name}`;
|
|
1757
|
+
if (!assignedById.has(key))
|
|
1758
|
+
assignedById.set(key, agent);
|
|
1759
|
+
};
|
|
1760
|
+
let liveAgents = [];
|
|
1761
|
+
try {
|
|
1762
|
+
const data = await input.client.getLiveAgents({
|
|
1763
|
+
initiative: input.initiativeId,
|
|
1764
|
+
includeIdle: true,
|
|
1765
|
+
});
|
|
1766
|
+
liveAgents = (Array.isArray(data.agents) ? data.agents : [])
|
|
1767
|
+
.map((raw) => {
|
|
1768
|
+
if (!raw || typeof raw !== "object")
|
|
1769
|
+
return null;
|
|
1770
|
+
const record = raw;
|
|
1771
|
+
const id = pickString(record, ["id", "agentId"]) ?? "";
|
|
1772
|
+
const name = pickString(record, ["name", "agentName"]) ?? (id ? `Agent ${id}` : "");
|
|
1773
|
+
if (!name)
|
|
1774
|
+
return null;
|
|
1775
|
+
return {
|
|
1776
|
+
id: id || `name:${name}`,
|
|
1777
|
+
name,
|
|
1778
|
+
domain: pickString(record, ["domain", "role"]),
|
|
1779
|
+
status: pickString(record, ["status"]),
|
|
1780
|
+
};
|
|
1781
|
+
})
|
|
1782
|
+
.filter((item) => item !== null);
|
|
1783
|
+
}
|
|
1784
|
+
catch (err) {
|
|
1785
|
+
warnings.push(`live agent lookup failed (${safeErrorMessage(err)})`);
|
|
1786
|
+
}
|
|
1787
|
+
const orchestrator = liveAgents.find((agent) => /holt|orchestrator/i.test(agent.name) ||
|
|
1788
|
+
/orchestrator/i.test(agent.domain ?? ""));
|
|
1789
|
+
if (orchestrator)
|
|
1790
|
+
addAgent(orchestrator);
|
|
1791
|
+
let assignmentSource = "fallback";
|
|
1792
|
+
try {
|
|
1793
|
+
const preflight = await input.client.delegationPreflight({
|
|
1794
|
+
intent: `${input.title}${input.summary ? `: ${input.summary}` : ""}`,
|
|
1795
|
+
});
|
|
1796
|
+
const recommendations = preflight.data?.recommended_split ?? [];
|
|
1797
|
+
const recommendedDomains = dedupeStrings(recommendations
|
|
1798
|
+
.map((entry) => String(entry.owner_domain ?? "").trim().toLowerCase())
|
|
1799
|
+
.filter(Boolean));
|
|
1800
|
+
for (const domain of recommendedDomains) {
|
|
1801
|
+
const matched = liveAgents.find((agent) => (agent.domain ?? "").toLowerCase().includes(domain));
|
|
1802
|
+
if (matched)
|
|
1803
|
+
addAgent(matched);
|
|
1804
|
+
}
|
|
1805
|
+
if (recommendedDomains.length > 0) {
|
|
1806
|
+
assignmentSource = "orchestrator";
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
catch (err) {
|
|
1810
|
+
warnings.push(`delegation preflight failed (${safeErrorMessage(err)})`);
|
|
1811
|
+
}
|
|
1812
|
+
if (assignedById.size === 0) {
|
|
1813
|
+
const text = `${input.title} ${input.summary ?? ""}`.toLowerCase();
|
|
1814
|
+
const fallbackDomains = [];
|
|
1815
|
+
if (/market|campaign|thread|article|tweet|copy/.test(text)) {
|
|
1816
|
+
fallbackDomains.push("marketing");
|
|
1817
|
+
}
|
|
1818
|
+
else if (/design|ux|ui|a11y|accessibility/.test(text)) {
|
|
1819
|
+
fallbackDomains.push("design");
|
|
1820
|
+
}
|
|
1821
|
+
else if (/ops|incident|runbook|reliability/.test(text)) {
|
|
1822
|
+
fallbackDomains.push("operations");
|
|
1823
|
+
}
|
|
1824
|
+
else if (/sales|deal|pipeline|mrr/.test(text)) {
|
|
1825
|
+
fallbackDomains.push("sales");
|
|
1826
|
+
}
|
|
1827
|
+
else {
|
|
1828
|
+
fallbackDomains.push("engineering", "product");
|
|
1829
|
+
}
|
|
1830
|
+
for (const domain of fallbackDomains) {
|
|
1831
|
+
const matched = liveAgents.find((agent) => (agent.domain ?? "").toLowerCase().includes(domain));
|
|
1832
|
+
if (matched)
|
|
1833
|
+
addAgent(matched);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
if (assignedById.size === 0 && liveAgents.length > 0) {
|
|
1837
|
+
addAgent(liveAgents[0]);
|
|
1838
|
+
warnings.push("using first available live agent as fallback");
|
|
1839
|
+
}
|
|
1840
|
+
const assignedAgents = Array.from(assignedById.values());
|
|
1841
|
+
const updatePayload = normalizeEntityMutationPayload({
|
|
1842
|
+
assigned_agent_ids: assignedAgents.map((agent) => agent.id),
|
|
1843
|
+
assigned_agent_names: assignedAgents.map((agent) => agent.name),
|
|
1844
|
+
assignment_source: assignmentSource,
|
|
1845
|
+
});
|
|
1846
|
+
let updatedEntity;
|
|
1847
|
+
try {
|
|
1848
|
+
updatedEntity = await input.client.updateEntity(input.entityType, input.entityId, updatePayload);
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
warnings.push(`assignment patch failed (${safeErrorMessage(err)})`);
|
|
1852
|
+
}
|
|
1853
|
+
return {
|
|
1854
|
+
ok: true,
|
|
1855
|
+
assignment_source: assignmentSource,
|
|
1856
|
+
assigned_agents: assignedAgents,
|
|
1857
|
+
warnings,
|
|
1858
|
+
...(updatedEntity ? { updated_entity: updatedEntity } : {}),
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
// =============================================================================
|
|
1862
|
+
// Factory
|
|
1863
|
+
// =============================================================================
|
|
1864
|
+
export function createHttpHandler(config, client, getSnapshot, onboarding, diagnostics, adapters) {
|
|
1865
|
+
const dashboardEnabled = config.dashboardEnabled ??
|
|
1866
|
+
true;
|
|
1867
|
+
const outboxAdapter = adapters?.outbox ?? defaultOutboxAdapter;
|
|
1868
|
+
const autoContinueRuns = new Map();
|
|
1869
|
+
let autoContinueTickInFlight = false;
|
|
1870
|
+
const AUTO_CONTINUE_TICK_MS = 2_500;
|
|
1871
|
+
function normalizeTokenBudget(value, fallback) {
|
|
1872
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1873
|
+
return Math.max(1_000, Math.round(value));
|
|
1874
|
+
}
|
|
1875
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1876
|
+
const parsed = Number(value);
|
|
1877
|
+
if (Number.isFinite(parsed)) {
|
|
1878
|
+
return Math.max(1_000, Math.round(parsed));
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return Math.max(1_000, Math.round(fallback));
|
|
1882
|
+
}
|
|
1883
|
+
function defaultAutoContinueTokenBudget() {
|
|
1884
|
+
const hours = readBudgetEnvNumber("ORGX_AUTO_CONTINUE_BUDGET_HOURS", 4, {
|
|
1885
|
+
min: 0.05,
|
|
1886
|
+
max: 24,
|
|
1887
|
+
});
|
|
1888
|
+
const fallback = DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.tokensPerHour *
|
|
1889
|
+
hours *
|
|
1890
|
+
DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.contingencyMultiplier;
|
|
1891
|
+
return normalizeTokenBudget(process.env.ORGX_AUTO_CONTINUE_TOKEN_BUDGET, fallback);
|
|
1892
|
+
}
|
|
1893
|
+
function estimateTokensForDurationHours(durationHours) {
|
|
1894
|
+
if (!Number.isFinite(durationHours) || durationHours <= 0)
|
|
1895
|
+
return 0;
|
|
1896
|
+
const raw = durationHours *
|
|
1897
|
+
DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.tokensPerHour *
|
|
1898
|
+
DEFAULT_TOKEN_BUDGET_ASSUMPTIONS.contingencyMultiplier;
|
|
1899
|
+
return Math.max(0, Math.round(raw));
|
|
1900
|
+
}
|
|
1901
|
+
function isSafePathSegment(value) {
|
|
1902
|
+
const normalized = value.trim();
|
|
1903
|
+
if (!normalized || normalized === "." || normalized === "..")
|
|
1904
|
+
return false;
|
|
1905
|
+
if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
|
|
1906
|
+
return false;
|
|
1907
|
+
}
|
|
1908
|
+
if (normalized.includes(".."))
|
|
1909
|
+
return false;
|
|
1910
|
+
return true;
|
|
1911
|
+
}
|
|
1912
|
+
function toFiniteNumber(value) {
|
|
1913
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
1914
|
+
return value;
|
|
1915
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1916
|
+
const parsed = Number(value);
|
|
1917
|
+
if (Number.isFinite(parsed))
|
|
1918
|
+
return parsed;
|
|
1919
|
+
}
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1922
|
+
function readOpenClawSessionSummary(input) {
|
|
1923
|
+
const agentId = input.agentId.trim();
|
|
1924
|
+
const sessionId = input.sessionId.trim();
|
|
1925
|
+
if (!agentId || !sessionId) {
|
|
1926
|
+
return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
|
|
1927
|
+
}
|
|
1928
|
+
if (!isSafePathSegment(agentId) || !isSafePathSegment(sessionId)) {
|
|
1929
|
+
return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
|
|
1930
|
+
}
|
|
1931
|
+
const jsonlPath = join(homedir(), ".openclaw", "agents", agentId, "sessions", `${sessionId}.jsonl`);
|
|
1932
|
+
try {
|
|
1933
|
+
if (!existsSync(jsonlPath)) {
|
|
1934
|
+
return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
|
|
1935
|
+
}
|
|
1936
|
+
const raw = readFileSync(jsonlPath, "utf8");
|
|
1937
|
+
const lines = raw.split("\n");
|
|
1938
|
+
let tokens = 0;
|
|
1939
|
+
let costUsd = 0;
|
|
1940
|
+
let hadError = false;
|
|
1941
|
+
let errorMessage = null;
|
|
1942
|
+
for (const line of lines) {
|
|
1943
|
+
const trimmed = line.trim();
|
|
1944
|
+
if (!trimmed)
|
|
1945
|
+
continue;
|
|
1946
|
+
try {
|
|
1947
|
+
const evt = JSON.parse(trimmed);
|
|
1948
|
+
if (evt.type !== "message")
|
|
1949
|
+
continue;
|
|
1950
|
+
const msg = evt.message;
|
|
1951
|
+
if (!msg || typeof msg !== "object")
|
|
1952
|
+
continue;
|
|
1953
|
+
const usage = msg.usage;
|
|
1954
|
+
if (usage && typeof usage === "object") {
|
|
1955
|
+
const totalTokens = toFiniteNumber(usage.totalTokens) ??
|
|
1956
|
+
toFiniteNumber(usage.total_tokens) ??
|
|
1957
|
+
null;
|
|
1958
|
+
const inputTokens = toFiniteNumber(usage.input) ?? 0;
|
|
1959
|
+
const outputTokens = toFiniteNumber(usage.output) ?? 0;
|
|
1960
|
+
const cacheReadTokens = toFiniteNumber(usage.cacheRead) ?? 0;
|
|
1961
|
+
const cacheWriteTokens = toFiniteNumber(usage.cacheWrite) ?? 0;
|
|
1962
|
+
tokens += Math.max(0, Math.round(totalTokens ??
|
|
1963
|
+
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens));
|
|
1964
|
+
const cost = usage.cost;
|
|
1965
|
+
const costTotal = cost ? toFiniteNumber(cost.total) : null;
|
|
1966
|
+
if (costTotal !== null) {
|
|
1967
|
+
costUsd += Math.max(0, costTotal);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
const stopReason = typeof msg.stopReason === "string" ? msg.stopReason : "";
|
|
1971
|
+
const msgError = typeof msg.errorMessage === "string" && msg.errorMessage.trim().length > 0
|
|
1972
|
+
? msg.errorMessage.trim()
|
|
1973
|
+
: null;
|
|
1974
|
+
if (stopReason === "error" || msgError) {
|
|
1975
|
+
hadError = true;
|
|
1976
|
+
errorMessage = msgError ?? errorMessage;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
catch {
|
|
1980
|
+
// Ignore malformed lines.
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
return {
|
|
1984
|
+
tokens,
|
|
1985
|
+
costUsd: Math.round(costUsd * 10_000) / 10_000,
|
|
1986
|
+
hadError,
|
|
1987
|
+
errorMessage,
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
catch {
|
|
1991
|
+
return { tokens: 0, costUsd: 0, hadError: false, errorMessage: null };
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
async function fetchInitiativeEntity(initiativeId) {
|
|
1995
|
+
try {
|
|
1996
|
+
const list = await client.listEntities("initiative", { limit: 200 });
|
|
1997
|
+
const match = list.data.find((candidate) => String(candidate?.id ?? "") === initiativeId);
|
|
1998
|
+
return match ?? null;
|
|
1999
|
+
}
|
|
2000
|
+
catch {
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
async function updateInitiativeMetadata(initiativeId, patch) {
|
|
2005
|
+
const existing = await fetchInitiativeEntity(initiativeId);
|
|
2006
|
+
const existingMeta = existing && typeof existing === "object"
|
|
2007
|
+
? getRecordMetadata(existing)
|
|
2008
|
+
: {};
|
|
2009
|
+
const nextMeta = { ...existingMeta, ...patch };
|
|
2010
|
+
await client.updateEntity("initiative", initiativeId, { metadata: nextMeta });
|
|
2011
|
+
}
|
|
2012
|
+
async function updateInitiativeAutoContinueState(input) {
|
|
2013
|
+
const now = new Date().toISOString();
|
|
2014
|
+
const patch = {
|
|
2015
|
+
auto_continue_enabled: true,
|
|
2016
|
+
auto_continue_status: input.run.status,
|
|
2017
|
+
auto_continue_stop_reason: input.run.stopReason,
|
|
2018
|
+
auto_continue_started_at: input.run.startedAt,
|
|
2019
|
+
auto_continue_stopped_at: input.run.stoppedAt,
|
|
2020
|
+
auto_continue_updated_at: now,
|
|
2021
|
+
auto_continue_token_budget: input.run.tokenBudget,
|
|
2022
|
+
auto_continue_tokens_used: input.run.tokensUsed,
|
|
2023
|
+
auto_continue_active_task_id: input.run.activeTaskId,
|
|
2024
|
+
auto_continue_active_run_id: input.run.activeRunId,
|
|
2025
|
+
auto_continue_active_task_token_estimate: input.run.activeTaskTokenEstimate,
|
|
2026
|
+
auto_continue_last_task_id: input.run.lastTaskId,
|
|
2027
|
+
auto_continue_last_run_id: input.run.lastRunId,
|
|
2028
|
+
auto_continue_include_verification: input.run.includeVerification,
|
|
2029
|
+
auto_continue_workstream_filter: input.run.allowedWorkstreamIds,
|
|
2030
|
+
...(input.run.lastError ? { auto_continue_last_error: input.run.lastError } : {}),
|
|
2031
|
+
};
|
|
2032
|
+
await updateInitiativeMetadata(input.initiativeId, patch);
|
|
2033
|
+
}
|
|
2034
|
+
async function stopAutoContinueRun(input) {
|
|
2035
|
+
const now = new Date().toISOString();
|
|
2036
|
+
input.run.status = "stopped";
|
|
2037
|
+
input.run.stopReason = input.reason;
|
|
2038
|
+
input.run.stoppedAt = now;
|
|
2039
|
+
input.run.updatedAt = now;
|
|
2040
|
+
input.run.stopRequested = false;
|
|
2041
|
+
input.run.activeRunId = null;
|
|
2042
|
+
input.run.activeTaskId = null;
|
|
2043
|
+
if (input.error)
|
|
2044
|
+
input.run.lastError = input.error;
|
|
2045
|
+
try {
|
|
2046
|
+
if (input.reason === "completed") {
|
|
2047
|
+
await client.updateEntity("initiative", input.run.initiativeId, {
|
|
2048
|
+
status: "completed",
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
else {
|
|
2052
|
+
await client.updateEntity("initiative", input.run.initiativeId, {
|
|
2053
|
+
status: "paused",
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
catch {
|
|
2058
|
+
// best effort; UI still derives paused state locally
|
|
2059
|
+
}
|
|
2060
|
+
try {
|
|
2061
|
+
await updateInitiativeAutoContinueState({
|
|
2062
|
+
initiativeId: input.run.initiativeId,
|
|
2063
|
+
run: input.run,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
catch {
|
|
2067
|
+
// best effort
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
async function tickAutoContinueRun(run) {
|
|
2071
|
+
if (run.status !== "running" && run.status !== "stopping")
|
|
2072
|
+
return;
|
|
2073
|
+
const now = new Date().toISOString();
|
|
2074
|
+
// 1) If we have an active run, wait for it to finish.
|
|
2075
|
+
if (run.activeRunId) {
|
|
2076
|
+
const record = getAgentRun(run.activeRunId);
|
|
2077
|
+
const pid = record?.pid ?? null;
|
|
2078
|
+
if (pid && isPidAlive(pid)) {
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
// Run finished (or pid missing). Mark stopped and auto-complete the task.
|
|
2082
|
+
if (record) {
|
|
2083
|
+
try {
|
|
2084
|
+
markAgentRunStopped(record.runId);
|
|
2085
|
+
}
|
|
2086
|
+
catch {
|
|
2087
|
+
// ignore
|
|
2088
|
+
}
|
|
2089
|
+
const summary = readOpenClawSessionSummary({
|
|
2090
|
+
agentId: record.agentId,
|
|
2091
|
+
sessionId: record.runId,
|
|
2092
|
+
});
|
|
2093
|
+
const modeledTokens = run.activeTaskTokenEstimate ?? 0;
|
|
2094
|
+
const consumedTokens = summary.tokens > 0 ? summary.tokens : modeledTokens;
|
|
2095
|
+
run.tokensUsed += Math.max(0, consumedTokens);
|
|
2096
|
+
run.activeTaskTokenEstimate = null;
|
|
2097
|
+
if (record.taskId) {
|
|
2098
|
+
try {
|
|
2099
|
+
await client.updateEntity("task", record.taskId, {
|
|
2100
|
+
status: summary.hadError ? "blocked" : "done",
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
catch (err) {
|
|
2104
|
+
run.lastError = safeErrorMessage(err);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
run.lastRunId = record.runId;
|
|
2108
|
+
run.lastTaskId = record.taskId ?? run.lastTaskId;
|
|
2109
|
+
run.activeRunId = null;
|
|
2110
|
+
run.activeTaskId = null;
|
|
2111
|
+
run.updatedAt = now;
|
|
2112
|
+
if (summary.hadError && summary.errorMessage) {
|
|
2113
|
+
run.lastError = summary.errorMessage;
|
|
2114
|
+
}
|
|
2115
|
+
try {
|
|
2116
|
+
await updateInitiativeAutoContinueState({
|
|
2117
|
+
initiativeId: run.initiativeId,
|
|
2118
|
+
run,
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
catch {
|
|
2122
|
+
// best effort
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
else {
|
|
2126
|
+
// No record; clear active pointers so we can continue.
|
|
2127
|
+
run.activeRunId = null;
|
|
2128
|
+
run.activeTaskId = null;
|
|
2129
|
+
}
|
|
2130
|
+
// If a stop was requested, finalize after the active run completes.
|
|
2131
|
+
if (run.stopRequested) {
|
|
2132
|
+
await stopAutoContinueRun({ run, reason: "stopped" });
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
if (run.stopRequested) {
|
|
2137
|
+
run.status = "stopping";
|
|
2138
|
+
run.updatedAt = now;
|
|
2139
|
+
await stopAutoContinueRun({ run, reason: "stopped" });
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
// 2) Enforce token guardrail before starting a new task.
|
|
2143
|
+
if (run.tokensUsed >= run.tokenBudget) {
|
|
2144
|
+
await stopAutoContinueRun({ run, reason: "budget_exhausted" });
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
// 3) Pick next-up task and dispatch.
|
|
2148
|
+
let graph;
|
|
2149
|
+
try {
|
|
2150
|
+
graph = await buildMissionControlGraph(client, run.initiativeId);
|
|
2151
|
+
}
|
|
2152
|
+
catch (err) {
|
|
2153
|
+
await stopAutoContinueRun({
|
|
2154
|
+
run,
|
|
2155
|
+
reason: "error",
|
|
2156
|
+
error: safeErrorMessage(err),
|
|
2157
|
+
});
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
const nodes = graph.nodes;
|
|
2161
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
2162
|
+
const taskNodes = nodes.filter((node) => node.type === "task");
|
|
2163
|
+
const todoTasks = taskNodes.filter((node) => isTodoStatus(node.status));
|
|
2164
|
+
if (todoTasks.length === 0) {
|
|
2165
|
+
await stopAutoContinueRun({ run, reason: "completed" });
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
const taskIsReady = (task) => task.dependencyIds.every((depId) => {
|
|
2169
|
+
const dependency = nodeById.get(depId);
|
|
2170
|
+
return dependency ? isDoneStatus(dependency.status) : true;
|
|
2171
|
+
});
|
|
2172
|
+
const taskHasBlockedParent = (task) => {
|
|
2173
|
+
const milestone = task.milestoneId ? nodeById.get(task.milestoneId) ?? null : null;
|
|
2174
|
+
const workstream = task.workstreamId ? nodeById.get(task.workstreamId) ?? null : null;
|
|
2175
|
+
return (milestone?.status?.toLowerCase() === "blocked" ||
|
|
2176
|
+
workstream?.status?.toLowerCase() === "blocked");
|
|
2177
|
+
};
|
|
2178
|
+
let nextTaskNode = null;
|
|
2179
|
+
for (const taskId of graph.recentTodos) {
|
|
2180
|
+
const node = nodeById.get(taskId);
|
|
2181
|
+
if (!node || node.type !== "task")
|
|
2182
|
+
continue;
|
|
2183
|
+
if (!isTodoStatus(node.status))
|
|
2184
|
+
continue;
|
|
2185
|
+
if (!run.includeVerification &&
|
|
2186
|
+
typeof node.title === "string" &&
|
|
2187
|
+
/^verification\s+scenario/i.test(node.title)) {
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
if (run.allowedWorkstreamIds &&
|
|
2191
|
+
node.workstreamId &&
|
|
2192
|
+
!run.allowedWorkstreamIds.includes(node.workstreamId)) {
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
2195
|
+
if (node.workstreamId) {
|
|
2196
|
+
const ws = nodeById.get(node.workstreamId);
|
|
2197
|
+
if (ws && !isInProgressStatus(ws.status)) {
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
if (!taskIsReady(node))
|
|
2202
|
+
continue;
|
|
2203
|
+
if (taskHasBlockedParent(node))
|
|
2204
|
+
continue;
|
|
2205
|
+
nextTaskNode = node;
|
|
2206
|
+
break;
|
|
2207
|
+
}
|
|
2208
|
+
if (!nextTaskNode) {
|
|
2209
|
+
await stopAutoContinueRun({ run, reason: "blocked" });
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const nextTaskTokenEstimate = estimateTokensForDurationHours(typeof nextTaskNode.expectedDurationHours === "number"
|
|
2213
|
+
? nextTaskNode.expectedDurationHours
|
|
2214
|
+
: 0);
|
|
2215
|
+
if (nextTaskTokenEstimate > 0 &&
|
|
2216
|
+
run.tokensUsed + nextTaskTokenEstimate > run.tokenBudget) {
|
|
2217
|
+
await stopAutoContinueRun({ run, reason: "budget_exhausted" });
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
const agentId = run.agentId || "main";
|
|
2221
|
+
const sessionId = randomUUID();
|
|
2222
|
+
const initiativeNode = nodes.find((node) => node.type === "initiative") ?? null;
|
|
2223
|
+
const workstreamTitle = nextTaskNode.workstreamId
|
|
2224
|
+
? nodeById.get(nextTaskNode.workstreamId)?.title ?? null
|
|
2225
|
+
: null;
|
|
2226
|
+
const milestoneTitle = nextTaskNode.milestoneId
|
|
2227
|
+
? nodeById.get(nextTaskNode.milestoneId)?.title ?? null
|
|
2228
|
+
: null;
|
|
2229
|
+
const message = [
|
|
2230
|
+
initiativeNode ? `Initiative: ${initiativeNode.title}` : null,
|
|
2231
|
+
workstreamTitle ? `Workstream: ${workstreamTitle}` : null,
|
|
2232
|
+
milestoneTitle ? `Milestone: ${milestoneTitle}` : null,
|
|
2233
|
+
"",
|
|
2234
|
+
`Task: ${nextTaskNode.title}`,
|
|
2235
|
+
"",
|
|
2236
|
+
"Execute this task. When finished, provide a concise completion summary and any relevant commands/notes.",
|
|
2237
|
+
]
|
|
2238
|
+
.filter((line) => typeof line === "string")
|
|
2239
|
+
.join("\n");
|
|
2240
|
+
try {
|
|
2241
|
+
await client.updateEntity("task", nextTaskNode.id, {
|
|
2242
|
+
status: "in_progress",
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
catch (err) {
|
|
2246
|
+
await stopAutoContinueRun({
|
|
2247
|
+
run,
|
|
2248
|
+
reason: "error",
|
|
2249
|
+
error: safeErrorMessage(err),
|
|
2250
|
+
});
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
upsertAgentContext({
|
|
2254
|
+
agentId,
|
|
2255
|
+
initiativeId: run.initiativeId,
|
|
2256
|
+
initiativeTitle: initiativeNode?.title ?? null,
|
|
2257
|
+
workstreamId: nextTaskNode.workstreamId,
|
|
2258
|
+
taskId: nextTaskNode.id,
|
|
2259
|
+
});
|
|
2260
|
+
const spawned = spawnOpenClawAgentTurn({
|
|
2261
|
+
agentId,
|
|
2262
|
+
sessionId,
|
|
2263
|
+
message,
|
|
2264
|
+
});
|
|
2265
|
+
upsertAgentRun({
|
|
2266
|
+
runId: sessionId,
|
|
2267
|
+
agentId,
|
|
2268
|
+
pid: spawned.pid,
|
|
2269
|
+
message,
|
|
2270
|
+
provider: null,
|
|
2271
|
+
model: null,
|
|
2272
|
+
initiativeId: run.initiativeId,
|
|
2273
|
+
initiativeTitle: initiativeNode?.title ?? null,
|
|
2274
|
+
workstreamId: nextTaskNode.workstreamId,
|
|
2275
|
+
taskId: nextTaskNode.id,
|
|
2276
|
+
startedAt: now,
|
|
2277
|
+
status: "running",
|
|
2278
|
+
});
|
|
2279
|
+
run.lastTaskId = nextTaskNode.id;
|
|
2280
|
+
run.lastRunId = sessionId;
|
|
2281
|
+
run.activeTaskId = nextTaskNode.id;
|
|
2282
|
+
run.activeRunId = sessionId;
|
|
2283
|
+
run.activeTaskTokenEstimate = nextTaskTokenEstimate > 0 ? nextTaskTokenEstimate : null;
|
|
2284
|
+
run.updatedAt = now;
|
|
2285
|
+
try {
|
|
2286
|
+
await client.updateEntity("initiative", run.initiativeId, { status: "active" });
|
|
2287
|
+
}
|
|
2288
|
+
catch {
|
|
2289
|
+
// best effort
|
|
2290
|
+
}
|
|
2291
|
+
try {
|
|
2292
|
+
await updateInitiativeAutoContinueState({
|
|
2293
|
+
initiativeId: run.initiativeId,
|
|
2294
|
+
run,
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
catch {
|
|
2298
|
+
// best effort
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
async function tickAllAutoContinue() {
|
|
2302
|
+
if (autoContinueTickInFlight)
|
|
2303
|
+
return;
|
|
2304
|
+
autoContinueTickInFlight = true;
|
|
2305
|
+
try {
|
|
2306
|
+
for (const run of autoContinueRuns.values()) {
|
|
2307
|
+
try {
|
|
2308
|
+
await tickAutoContinueRun(run);
|
|
2309
|
+
}
|
|
2310
|
+
catch (err) {
|
|
2311
|
+
// Never let one loop crash the whole handler.
|
|
2312
|
+
run.lastError = safeErrorMessage(err);
|
|
2313
|
+
run.updatedAt = new Date().toISOString();
|
|
2314
|
+
await stopAutoContinueRun({ run, reason: "error", error: run.lastError });
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
finally {
|
|
2319
|
+
autoContinueTickInFlight = false;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
const autoContinueTimer = setInterval(() => {
|
|
2323
|
+
void tickAllAutoContinue();
|
|
2324
|
+
}, AUTO_CONTINUE_TICK_MS);
|
|
2325
|
+
autoContinueTimer.unref?.();
|
|
2326
|
+
return async function handler(req, res) {
|
|
2327
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
2328
|
+
const rawUrl = req.url ?? "/";
|
|
2329
|
+
const [path, queryString] = rawUrl.split("?", 2);
|
|
2330
|
+
const url = path;
|
|
2331
|
+
const searchParams = new URLSearchParams(queryString ?? "");
|
|
2332
|
+
// Only handle /orgx paths — return false for everything else
|
|
2333
|
+
if (!url.startsWith("/orgx")) {
|
|
2334
|
+
return false;
|
|
2335
|
+
}
|
|
2336
|
+
// Handle CORS preflight
|
|
2337
|
+
if (method === "OPTIONS") {
|
|
2338
|
+
if (url.startsWith("/orgx/api/") && !isTrustedRequestSource(req.headers)) {
|
|
2339
|
+
sendJson(res, 403, {
|
|
2340
|
+
error: "Cross-origin browser requests are blocked for /orgx/api endpoints.",
|
|
2341
|
+
});
|
|
2342
|
+
return true;
|
|
2343
|
+
}
|
|
2344
|
+
res.writeHead(204, {
|
|
2345
|
+
...SECURITY_HEADERS,
|
|
2346
|
+
...CORS_HEADERS,
|
|
2347
|
+
});
|
|
2348
|
+
res.end();
|
|
2349
|
+
return true;
|
|
231
2350
|
}
|
|
232
2351
|
// ── API endpoints ──────────────────────────────────────────────────────
|
|
233
2352
|
if (url.startsWith("/orgx/api/")) {
|
|
2353
|
+
if (!isTrustedRequestSource(req.headers)) {
|
|
2354
|
+
sendJson(res, 403, {
|
|
2355
|
+
error: "Cross-origin browser requests are blocked for /orgx/api endpoints.",
|
|
2356
|
+
});
|
|
2357
|
+
return true;
|
|
2358
|
+
}
|
|
234
2359
|
const route = url.replace("/orgx/api/", "").replace(/\/+$/, "");
|
|
235
2360
|
const decisionApproveMatch = route.match(/^live\/decisions\/([^/]+)\/approve$/);
|
|
236
2361
|
const runActionMatch = route.match(/^runs\/([^/]+)\/actions\/([^/]+)$/);
|
|
237
2362
|
const runCheckpointsMatch = route.match(/^runs\/([^/]+)\/checkpoints$/);
|
|
238
2363
|
const runCheckpointRestoreMatch = route.match(/^runs\/([^/]+)\/checkpoints\/([^/]+)\/restore$/);
|
|
239
2364
|
const isDelegationPreflight = route === "delegation/preflight";
|
|
2365
|
+
const isMissionControlAutoAssignmentRoute = route === "mission-control/assignments/auto";
|
|
2366
|
+
const isMissionControlAutoContinueStartRoute = route === "mission-control/auto-continue/start";
|
|
2367
|
+
const isMissionControlAutoContinueStopRoute = route === "mission-control/auto-continue/stop";
|
|
240
2368
|
const isEntitiesRoute = route === "entities";
|
|
2369
|
+
const entityActionMatch = route.match(/^entities\/([^/]+)\/([^/]+)\/([^/]+)$/);
|
|
2370
|
+
const isOnboardingStartRoute = route === "onboarding/start";
|
|
2371
|
+
const isOnboardingStatusRoute = route === "onboarding/status";
|
|
2372
|
+
const isOnboardingManualKeyRoute = route === "onboarding/manual-key";
|
|
2373
|
+
const isOnboardingDisconnectRoute = route === "onboarding/disconnect";
|
|
2374
|
+
const isLiveActivityHeadlineRoute = route === "live/activity/headline";
|
|
2375
|
+
const isAgentLaunchRoute = route === "agents/launch";
|
|
2376
|
+
const isAgentStopRoute = route === "agents/stop";
|
|
2377
|
+
const isAgentRestartRoute = route === "agents/restart";
|
|
2378
|
+
const isByokSettingsRoute = route === "settings/byok";
|
|
2379
|
+
if (method === "POST" && isOnboardingStartRoute) {
|
|
2380
|
+
try {
|
|
2381
|
+
const payload = await parseJsonRequest(req);
|
|
2382
|
+
const started = await onboarding.startPairing({
|
|
2383
|
+
openclawVersion: pickString(payload, ["openclawVersion", "openclaw_version"]) ??
|
|
2384
|
+
undefined,
|
|
2385
|
+
platform: pickString(payload, ["platform"]) ?? undefined,
|
|
2386
|
+
deviceName: pickString(payload, ["deviceName", "device_name"]) ?? undefined,
|
|
2387
|
+
});
|
|
2388
|
+
sendJson(res, 200, {
|
|
2389
|
+
ok: true,
|
|
2390
|
+
data: {
|
|
2391
|
+
pairingId: started.pairingId,
|
|
2392
|
+
connectUrl: started.connectUrl,
|
|
2393
|
+
expiresAt: started.expiresAt,
|
|
2394
|
+
pollIntervalMs: started.pollIntervalMs,
|
|
2395
|
+
state: getOnboardingState(started.state),
|
|
2396
|
+
},
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
catch (err) {
|
|
2400
|
+
sendJson(res, 400, {
|
|
2401
|
+
ok: false,
|
|
2402
|
+
error: safeErrorMessage(err),
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
return true;
|
|
2406
|
+
}
|
|
2407
|
+
if (method === "GET" && isOnboardingStatusRoute) {
|
|
2408
|
+
try {
|
|
2409
|
+
const state = await onboarding.getStatus();
|
|
2410
|
+
sendJson(res, 200, {
|
|
2411
|
+
ok: true,
|
|
2412
|
+
data: getOnboardingState(state),
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
catch (err) {
|
|
2416
|
+
sendJson(res, 500, {
|
|
2417
|
+
ok: false,
|
|
2418
|
+
error: safeErrorMessage(err),
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
return true;
|
|
2422
|
+
}
|
|
2423
|
+
if (method === "POST" && isOnboardingManualKeyRoute) {
|
|
2424
|
+
try {
|
|
2425
|
+
const payload = await parseJsonRequest(req);
|
|
2426
|
+
const authHeader = pickHeaderString(req.headers, ["authorization"]);
|
|
2427
|
+
const bearerApiKey = authHeader && authHeader.toLowerCase().startsWith("bearer ")
|
|
2428
|
+
? authHeader.slice("bearer ".length).trim()
|
|
2429
|
+
: null;
|
|
2430
|
+
const headerApiKey = pickHeaderString(req.headers, [
|
|
2431
|
+
"x-orgx-api-key",
|
|
2432
|
+
"x-api-key",
|
|
2433
|
+
]);
|
|
2434
|
+
const apiKey = pickString(payload, ["apiKey", "api_key"]) ??
|
|
2435
|
+
headerApiKey ??
|
|
2436
|
+
bearerApiKey;
|
|
2437
|
+
if (!apiKey) {
|
|
2438
|
+
sendJson(res, 400, {
|
|
2439
|
+
ok: false,
|
|
2440
|
+
error: "apiKey is required",
|
|
2441
|
+
});
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
const requestedUserId = pickString(payload, ["userId", "user_id"]) ??
|
|
2445
|
+
pickHeaderString(req.headers, ["x-orgx-user-id", "x-user-id"]) ??
|
|
2446
|
+
undefined;
|
|
2447
|
+
const userId = isUserScopedApiKey(apiKey) ? undefined : requestedUserId;
|
|
2448
|
+
const state = await onboarding.submitManualKey({
|
|
2449
|
+
apiKey,
|
|
2450
|
+
userId,
|
|
2451
|
+
});
|
|
2452
|
+
sendJson(res, 200, {
|
|
2453
|
+
ok: true,
|
|
2454
|
+
data: getOnboardingState(state),
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
catch (err) {
|
|
2458
|
+
sendJson(res, 400, {
|
|
2459
|
+
ok: false,
|
|
2460
|
+
error: safeErrorMessage(err),
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
return true;
|
|
2464
|
+
}
|
|
2465
|
+
if (method === "POST" && isOnboardingDisconnectRoute) {
|
|
2466
|
+
try {
|
|
2467
|
+
const state = await onboarding.disconnect();
|
|
2468
|
+
sendJson(res, 200, {
|
|
2469
|
+
ok: true,
|
|
2470
|
+
data: getOnboardingState(state),
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
catch (err) {
|
|
2474
|
+
sendJson(res, 500, {
|
|
2475
|
+
ok: false,
|
|
2476
|
+
error: safeErrorMessage(err),
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
return true;
|
|
2480
|
+
}
|
|
2481
|
+
if (method === "POST" && isAgentLaunchRoute) {
|
|
2482
|
+
try {
|
|
2483
|
+
const payload = await parseJsonRequest(req);
|
|
2484
|
+
const agentId = (pickString(payload, ["agentId", "agent_id", "id"]) ??
|
|
2485
|
+
searchParams.get("agentId") ??
|
|
2486
|
+
searchParams.get("agent_id") ??
|
|
2487
|
+
searchParams.get("id") ??
|
|
2488
|
+
"")
|
|
2489
|
+
.trim();
|
|
2490
|
+
if (!agentId) {
|
|
2491
|
+
sendJson(res, 400, { ok: false, error: "agentId is required" });
|
|
2492
|
+
return true;
|
|
2493
|
+
}
|
|
2494
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
|
|
2495
|
+
sendJson(res, 400, {
|
|
2496
|
+
ok: false,
|
|
2497
|
+
error: "agentId must be a simple identifier (letters, numbers, _ or -).",
|
|
2498
|
+
});
|
|
2499
|
+
return true;
|
|
2500
|
+
}
|
|
2501
|
+
const sessionId = (pickString(payload, ["sessionId", "session_id"]) ??
|
|
2502
|
+
searchParams.get("sessionId") ??
|
|
2503
|
+
searchParams.get("session_id") ??
|
|
2504
|
+
"")
|
|
2505
|
+
.trim() ||
|
|
2506
|
+
randomUUID();
|
|
2507
|
+
const initiativeId = pickString(payload, ["initiativeId", "initiative_id"]) ??
|
|
2508
|
+
searchParams.get("initiativeId") ??
|
|
2509
|
+
searchParams.get("initiative_id") ??
|
|
2510
|
+
null;
|
|
2511
|
+
const initiativeTitle = pickString(payload, [
|
|
2512
|
+
"initiativeTitle",
|
|
2513
|
+
"initiative_title",
|
|
2514
|
+
"initiativeName",
|
|
2515
|
+
"initiative_name",
|
|
2516
|
+
]) ??
|
|
2517
|
+
searchParams.get("initiativeTitle") ??
|
|
2518
|
+
searchParams.get("initiative_title") ??
|
|
2519
|
+
searchParams.get("initiativeName") ??
|
|
2520
|
+
searchParams.get("initiative_name") ??
|
|
2521
|
+
null;
|
|
2522
|
+
const workstreamId = pickString(payload, ["workstreamId", "workstream_id"]) ??
|
|
2523
|
+
searchParams.get("workstreamId") ??
|
|
2524
|
+
searchParams.get("workstream_id") ??
|
|
2525
|
+
null;
|
|
2526
|
+
const taskId = pickString(payload, ["taskId", "task_id"]) ??
|
|
2527
|
+
searchParams.get("taskId") ??
|
|
2528
|
+
searchParams.get("task_id") ??
|
|
2529
|
+
null;
|
|
2530
|
+
const thinking = (pickString(payload, ["thinking"]) ??
|
|
2531
|
+
searchParams.get("thinking") ??
|
|
2532
|
+
"")
|
|
2533
|
+
.trim() || null;
|
|
2534
|
+
const provider = normalizeOpenClawProvider(pickString(payload, ["provider", "modelProvider", "model_provider"]) ??
|
|
2535
|
+
searchParams.get("provider") ??
|
|
2536
|
+
searchParams.get("modelProvider") ??
|
|
2537
|
+
searchParams.get("model_provider") ??
|
|
2538
|
+
null);
|
|
2539
|
+
const requestedModel = (pickString(payload, ["model", "modelId", "model_id"]) ??
|
|
2540
|
+
searchParams.get("model") ??
|
|
2541
|
+
searchParams.get("modelId") ??
|
|
2542
|
+
searchParams.get("model_id") ??
|
|
2543
|
+
"")
|
|
2544
|
+
.trim() || null;
|
|
2545
|
+
const dryRunRaw = payload.dryRun ??
|
|
2546
|
+
payload.dry_run ??
|
|
2547
|
+
searchParams.get("dryRun") ??
|
|
2548
|
+
searchParams.get("dry_run") ??
|
|
2549
|
+
null;
|
|
2550
|
+
const dryRun = typeof dryRunRaw === "boolean"
|
|
2551
|
+
? dryRunRaw
|
|
2552
|
+
: parseBooleanQuery(typeof dryRunRaw === "string" ? dryRunRaw : null);
|
|
2553
|
+
let requiresPremiumLaunch = Boolean(provider) || modelImpliesByok(requestedModel);
|
|
2554
|
+
if (!requiresPremiumLaunch) {
|
|
2555
|
+
try {
|
|
2556
|
+
const agents = await listOpenClawAgents();
|
|
2557
|
+
const agentEntry = agents.find((entry) => String(entry.id ?? "").trim() === agentId) ??
|
|
2558
|
+
null;
|
|
2559
|
+
const agentModel = agentEntry && typeof agentEntry.model === "string"
|
|
2560
|
+
? agentEntry.model
|
|
2561
|
+
: null;
|
|
2562
|
+
requiresPremiumLaunch = modelImpliesByok(agentModel);
|
|
2563
|
+
}
|
|
2564
|
+
catch {
|
|
2565
|
+
// ignore
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
if (requiresPremiumLaunch) {
|
|
2569
|
+
const billingStatus = await fetchBillingStatusSafe(client);
|
|
2570
|
+
if (billingStatus && billingStatus.plan === "free") {
|
|
2571
|
+
const pricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
|
|
2572
|
+
sendJson(res, 402, {
|
|
2573
|
+
ok: false,
|
|
2574
|
+
code: "upgrade_required",
|
|
2575
|
+
error: "BYOK agent launch requires a paid OrgX plan. Upgrade, then retry.",
|
|
2576
|
+
currentPlan: billingStatus.plan,
|
|
2577
|
+
requiredPlan: "starter",
|
|
2578
|
+
actions: {
|
|
2579
|
+
checkout: "/orgx/api/billing/checkout",
|
|
2580
|
+
portal: "/orgx/api/billing/portal",
|
|
2581
|
+
pricing: pricingUrl,
|
|
2582
|
+
},
|
|
2583
|
+
});
|
|
2584
|
+
return true;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
const messageInput = (pickString(payload, ["message", "prompt", "text"]) ??
|
|
2588
|
+
searchParams.get("message") ??
|
|
2589
|
+
searchParams.get("prompt") ??
|
|
2590
|
+
searchParams.get("text") ??
|
|
2591
|
+
"")
|
|
2592
|
+
.trim();
|
|
2593
|
+
const message = messageInput ||
|
|
2594
|
+
(initiativeTitle
|
|
2595
|
+
? `Kick off: ${initiativeTitle}`
|
|
2596
|
+
: initiativeId
|
|
2597
|
+
? `Kick off initiative ${initiativeId}`
|
|
2598
|
+
: `Kick off agent ${agentId}`);
|
|
2599
|
+
if (dryRun) {
|
|
2600
|
+
sendJson(res, 200, {
|
|
2601
|
+
ok: true,
|
|
2602
|
+
dryRun: true,
|
|
2603
|
+
agentId,
|
|
2604
|
+
initiativeId,
|
|
2605
|
+
workstreamId,
|
|
2606
|
+
taskId,
|
|
2607
|
+
requiresPremiumLaunch,
|
|
2608
|
+
startedAt: new Date().toISOString(),
|
|
2609
|
+
message,
|
|
2610
|
+
});
|
|
2611
|
+
return true;
|
|
2612
|
+
}
|
|
2613
|
+
let routedProvider = null;
|
|
2614
|
+
let routedModel = null;
|
|
2615
|
+
if (provider) {
|
|
2616
|
+
const routed = await configureOpenClawProviderRouting({
|
|
2617
|
+
agentId,
|
|
2618
|
+
provider,
|
|
2619
|
+
requestedModel,
|
|
2620
|
+
});
|
|
2621
|
+
routedProvider = routed.provider;
|
|
2622
|
+
routedModel = routed.model;
|
|
2623
|
+
}
|
|
2624
|
+
upsertAgentContext({
|
|
2625
|
+
agentId,
|
|
2626
|
+
initiativeId,
|
|
2627
|
+
initiativeTitle,
|
|
2628
|
+
workstreamId,
|
|
2629
|
+
taskId,
|
|
2630
|
+
});
|
|
2631
|
+
const spawned = spawnOpenClawAgentTurn({
|
|
2632
|
+
agentId,
|
|
2633
|
+
sessionId,
|
|
2634
|
+
message,
|
|
2635
|
+
thinking,
|
|
2636
|
+
});
|
|
2637
|
+
upsertAgentRun({
|
|
2638
|
+
runId: sessionId,
|
|
2639
|
+
agentId,
|
|
2640
|
+
pid: spawned.pid,
|
|
2641
|
+
message,
|
|
2642
|
+
provider: routedProvider,
|
|
2643
|
+
model: routedModel,
|
|
2644
|
+
initiativeId,
|
|
2645
|
+
initiativeTitle,
|
|
2646
|
+
workstreamId,
|
|
2647
|
+
taskId,
|
|
2648
|
+
startedAt: new Date().toISOString(),
|
|
2649
|
+
status: "running",
|
|
2650
|
+
});
|
|
2651
|
+
sendJson(res, 202, {
|
|
2652
|
+
ok: true,
|
|
2653
|
+
agentId,
|
|
2654
|
+
sessionId,
|
|
2655
|
+
pid: spawned.pid,
|
|
2656
|
+
provider: routedProvider,
|
|
2657
|
+
model: routedModel,
|
|
2658
|
+
initiativeId,
|
|
2659
|
+
workstreamId,
|
|
2660
|
+
taskId,
|
|
2661
|
+
startedAt: new Date().toISOString(),
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
catch (err) {
|
|
2665
|
+
sendJson(res, 500, {
|
|
2666
|
+
ok: false,
|
|
2667
|
+
error: safeErrorMessage(err),
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
return true;
|
|
2671
|
+
}
|
|
2672
|
+
if (method === "POST" && isAgentStopRoute) {
|
|
2673
|
+
try {
|
|
2674
|
+
const payload = await parseJsonRequest(req);
|
|
2675
|
+
const runId = (pickString(payload, ["runId", "run_id", "sessionId", "session_id"]) ??
|
|
2676
|
+
searchParams.get("runId") ??
|
|
2677
|
+
searchParams.get("run_id") ??
|
|
2678
|
+
searchParams.get("sessionId") ??
|
|
2679
|
+
searchParams.get("session_id") ??
|
|
2680
|
+
"")
|
|
2681
|
+
.trim();
|
|
2682
|
+
if (!runId) {
|
|
2683
|
+
sendJson(res, 400, { ok: false, error: "runId is required" });
|
|
2684
|
+
return true;
|
|
2685
|
+
}
|
|
2686
|
+
const record = getAgentRun(runId);
|
|
2687
|
+
if (!record) {
|
|
2688
|
+
sendJson(res, 404, { ok: false, error: "Run not found" });
|
|
2689
|
+
return true;
|
|
2690
|
+
}
|
|
2691
|
+
if (!record.pid) {
|
|
2692
|
+
sendJson(res, 409, { ok: false, error: "Run has no tracked pid" });
|
|
2693
|
+
return true;
|
|
2694
|
+
}
|
|
2695
|
+
const result = await stopDetachedProcess(record.pid);
|
|
2696
|
+
const updated = markAgentRunStopped(runId);
|
|
2697
|
+
sendJson(res, 200, {
|
|
2698
|
+
ok: true,
|
|
2699
|
+
runId,
|
|
2700
|
+
agentId: record.agentId,
|
|
2701
|
+
pid: record.pid,
|
|
2702
|
+
stopped: result.stopped,
|
|
2703
|
+
wasRunning: result.wasRunning,
|
|
2704
|
+
record: updated,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
catch (err) {
|
|
2708
|
+
sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
|
|
2709
|
+
}
|
|
2710
|
+
return true;
|
|
2711
|
+
}
|
|
2712
|
+
if (method === "POST" && isAgentRestartRoute) {
|
|
2713
|
+
try {
|
|
2714
|
+
const payload = await parseJsonRequest(req);
|
|
2715
|
+
const previousRunId = (pickString(payload, ["runId", "run_id", "sessionId", "session_id"]) ??
|
|
2716
|
+
searchParams.get("runId") ??
|
|
2717
|
+
searchParams.get("run_id") ??
|
|
2718
|
+
searchParams.get("sessionId") ??
|
|
2719
|
+
searchParams.get("session_id") ??
|
|
2720
|
+
"")
|
|
2721
|
+
.trim();
|
|
2722
|
+
if (!previousRunId) {
|
|
2723
|
+
sendJson(res, 400, { ok: false, error: "runId is required" });
|
|
2724
|
+
return true;
|
|
2725
|
+
}
|
|
2726
|
+
const record = getAgentRun(previousRunId);
|
|
2727
|
+
if (!record) {
|
|
2728
|
+
sendJson(res, 404, { ok: false, error: "Run not found" });
|
|
2729
|
+
return true;
|
|
2730
|
+
}
|
|
2731
|
+
const messageOverride = (pickString(payload, ["message", "prompt", "text"]) ??
|
|
2732
|
+
searchParams.get("message") ??
|
|
2733
|
+
searchParams.get("prompt") ??
|
|
2734
|
+
searchParams.get("text") ??
|
|
2735
|
+
"")
|
|
2736
|
+
.trim() || null;
|
|
2737
|
+
const providerOverride = normalizeOpenClawProvider(pickString(payload, ["provider", "modelProvider", "model_provider"]) ??
|
|
2738
|
+
searchParams.get("provider") ??
|
|
2739
|
+
searchParams.get("modelProvider") ??
|
|
2740
|
+
searchParams.get("model_provider") ??
|
|
2741
|
+
record.provider ??
|
|
2742
|
+
null);
|
|
2743
|
+
const requestedModel = (pickString(payload, ["model", "modelId", "model_id"]) ??
|
|
2744
|
+
searchParams.get("model") ??
|
|
2745
|
+
searchParams.get("modelId") ??
|
|
2746
|
+
searchParams.get("model_id") ??
|
|
2747
|
+
record.model ??
|
|
2748
|
+
"")
|
|
2749
|
+
.trim() || null;
|
|
2750
|
+
let requiresPremiumRestart = Boolean(providerOverride) ||
|
|
2751
|
+
modelImpliesByok(requestedModel) ||
|
|
2752
|
+
modelImpliesByok(record.model ?? null);
|
|
2753
|
+
if (!requiresPremiumRestart) {
|
|
2754
|
+
try {
|
|
2755
|
+
const agents = await listOpenClawAgents();
|
|
2756
|
+
const agentEntry = agents.find((entry) => String(entry.id ?? "").trim() === record.agentId) ?? null;
|
|
2757
|
+
const agentModel = agentEntry && typeof agentEntry.model === "string"
|
|
2758
|
+
? agentEntry.model
|
|
2759
|
+
: null;
|
|
2760
|
+
requiresPremiumRestart = modelImpliesByok(agentModel);
|
|
2761
|
+
}
|
|
2762
|
+
catch {
|
|
2763
|
+
// ignore
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
if (requiresPremiumRestart) {
|
|
2767
|
+
const billingStatus = await fetchBillingStatusSafe(client);
|
|
2768
|
+
if (billingStatus && billingStatus.plan === "free") {
|
|
2769
|
+
const pricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
|
|
2770
|
+
sendJson(res, 402, {
|
|
2771
|
+
ok: false,
|
|
2772
|
+
code: "upgrade_required",
|
|
2773
|
+
error: "BYOK agent launch requires a paid OrgX plan. Upgrade, then retry.",
|
|
2774
|
+
currentPlan: billingStatus.plan,
|
|
2775
|
+
requiredPlan: "starter",
|
|
2776
|
+
actions: {
|
|
2777
|
+
checkout: "/orgx/api/billing/checkout",
|
|
2778
|
+
portal: "/orgx/api/billing/portal",
|
|
2779
|
+
pricing: pricingUrl,
|
|
2780
|
+
},
|
|
2781
|
+
});
|
|
2782
|
+
return true;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
const sessionId = randomUUID();
|
|
2786
|
+
const message = messageOverride ?? record.message ?? `Restart agent ${record.agentId}`;
|
|
2787
|
+
let routedProvider = providerOverride ?? null;
|
|
2788
|
+
let routedModel = requestedModel ?? null;
|
|
2789
|
+
if (providerOverride) {
|
|
2790
|
+
const routed = await configureOpenClawProviderRouting({
|
|
2791
|
+
agentId: record.agentId,
|
|
2792
|
+
provider: providerOverride,
|
|
2793
|
+
requestedModel,
|
|
2794
|
+
});
|
|
2795
|
+
routedProvider = routed.provider;
|
|
2796
|
+
routedModel = routed.model;
|
|
2797
|
+
}
|
|
2798
|
+
upsertAgentContext({
|
|
2799
|
+
agentId: record.agentId,
|
|
2800
|
+
initiativeId: record.initiativeId,
|
|
2801
|
+
initiativeTitle: record.initiativeTitle,
|
|
2802
|
+
workstreamId: record.workstreamId,
|
|
2803
|
+
taskId: record.taskId,
|
|
2804
|
+
});
|
|
2805
|
+
const spawned = spawnOpenClawAgentTurn({
|
|
2806
|
+
agentId: record.agentId,
|
|
2807
|
+
sessionId,
|
|
2808
|
+
message,
|
|
2809
|
+
});
|
|
2810
|
+
upsertAgentRun({
|
|
2811
|
+
runId: sessionId,
|
|
2812
|
+
agentId: record.agentId,
|
|
2813
|
+
pid: spawned.pid,
|
|
2814
|
+
message,
|
|
2815
|
+
provider: routedProvider,
|
|
2816
|
+
model: routedModel,
|
|
2817
|
+
initiativeId: record.initiativeId,
|
|
2818
|
+
initiativeTitle: record.initiativeTitle,
|
|
2819
|
+
workstreamId: record.workstreamId,
|
|
2820
|
+
taskId: record.taskId,
|
|
2821
|
+
startedAt: new Date().toISOString(),
|
|
2822
|
+
status: "running",
|
|
2823
|
+
});
|
|
2824
|
+
sendJson(res, 202, {
|
|
2825
|
+
ok: true,
|
|
2826
|
+
previousRunId,
|
|
2827
|
+
sessionId,
|
|
2828
|
+
agentId: record.agentId,
|
|
2829
|
+
pid: spawned.pid,
|
|
2830
|
+
provider: routedProvider,
|
|
2831
|
+
model: routedModel,
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
catch (err) {
|
|
2835
|
+
sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
|
|
2836
|
+
}
|
|
2837
|
+
return true;
|
|
2838
|
+
}
|
|
2839
|
+
if (method === "POST" && isMissionControlAutoContinueStartRoute) {
|
|
2840
|
+
try {
|
|
2841
|
+
const payload = await parseJsonRequest(req);
|
|
2842
|
+
const initiativeId = (pickString(payload, ["initiativeId", "initiative_id"]) ??
|
|
2843
|
+
searchParams.get("initiativeId") ??
|
|
2844
|
+
searchParams.get("initiative_id") ??
|
|
2845
|
+
"")
|
|
2846
|
+
.trim();
|
|
2847
|
+
if (!initiativeId) {
|
|
2848
|
+
sendJson(res, 400, { ok: false, error: "initiativeId is required" });
|
|
2849
|
+
return true;
|
|
2850
|
+
}
|
|
2851
|
+
const agentIdRaw = (pickString(payload, ["agentId", "agent_id"]) ??
|
|
2852
|
+
searchParams.get("agentId") ??
|
|
2853
|
+
searchParams.get("agent_id") ??
|
|
2854
|
+
"main")
|
|
2855
|
+
.trim();
|
|
2856
|
+
const agentId = agentIdRaw || "main";
|
|
2857
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
|
|
2858
|
+
sendJson(res, 400, {
|
|
2859
|
+
ok: false,
|
|
2860
|
+
error: "agentId must be a simple identifier (letters, numbers, _ or -).",
|
|
2861
|
+
});
|
|
2862
|
+
return true;
|
|
2863
|
+
}
|
|
2864
|
+
let requiresPremiumAutoContinue = false;
|
|
2865
|
+
try {
|
|
2866
|
+
const agents = await listOpenClawAgents();
|
|
2867
|
+
const agentEntry = agents.find((entry) => String(entry.id ?? "").trim() === agentId) ??
|
|
2868
|
+
null;
|
|
2869
|
+
const agentModel = agentEntry && typeof agentEntry.model === "string"
|
|
2870
|
+
? agentEntry.model
|
|
2871
|
+
: null;
|
|
2872
|
+
requiresPremiumAutoContinue = modelImpliesByok(agentModel);
|
|
2873
|
+
}
|
|
2874
|
+
catch {
|
|
2875
|
+
// ignore
|
|
2876
|
+
}
|
|
2877
|
+
if (requiresPremiumAutoContinue) {
|
|
2878
|
+
const billingStatus = await fetchBillingStatusSafe(client);
|
|
2879
|
+
if (billingStatus && billingStatus.plan === "free") {
|
|
2880
|
+
const pricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
|
|
2881
|
+
sendJson(res, 402, {
|
|
2882
|
+
ok: false,
|
|
2883
|
+
code: "upgrade_required",
|
|
2884
|
+
error: "Auto-continue for BYOK agents requires a paid OrgX plan. Upgrade, then retry.",
|
|
2885
|
+
currentPlan: billingStatus.plan,
|
|
2886
|
+
requiredPlan: "starter",
|
|
2887
|
+
actions: {
|
|
2888
|
+
checkout: "/orgx/api/billing/checkout",
|
|
2889
|
+
portal: "/orgx/api/billing/portal",
|
|
2890
|
+
pricing: pricingUrl,
|
|
2891
|
+
},
|
|
2892
|
+
});
|
|
2893
|
+
return true;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
const tokenBudget = pickNumber(payload, [
|
|
2897
|
+
"tokenBudget",
|
|
2898
|
+
"token_budget",
|
|
2899
|
+
"tokenBudgetTokens",
|
|
2900
|
+
"token_budget_tokens",
|
|
2901
|
+
"maxTokens",
|
|
2902
|
+
"max_tokens",
|
|
2903
|
+
]) ??
|
|
2904
|
+
searchParams.get("tokenBudget") ??
|
|
2905
|
+
searchParams.get("token_budget") ??
|
|
2906
|
+
searchParams.get("tokenBudgetTokens") ??
|
|
2907
|
+
searchParams.get("token_budget_tokens") ??
|
|
2908
|
+
searchParams.get("maxTokens") ??
|
|
2909
|
+
searchParams.get("max_tokens") ??
|
|
2910
|
+
null;
|
|
2911
|
+
const includeVerificationRaw = payload.includeVerification ??
|
|
2912
|
+
payload.include_verification ??
|
|
2913
|
+
searchParams.get("includeVerification") ??
|
|
2914
|
+
searchParams.get("include_verification") ??
|
|
2915
|
+
null;
|
|
2916
|
+
const includeVerification = typeof includeVerificationRaw === "boolean"
|
|
2917
|
+
? includeVerificationRaw
|
|
2918
|
+
: parseBooleanQuery(typeof includeVerificationRaw === "string"
|
|
2919
|
+
? includeVerificationRaw
|
|
2920
|
+
: null);
|
|
2921
|
+
const workstreamFilter = dedupeStrings([
|
|
2922
|
+
...pickStringArray(payload, [
|
|
2923
|
+
"workstreamIds",
|
|
2924
|
+
"workstream_ids",
|
|
2925
|
+
"workstreamId",
|
|
2926
|
+
"workstream_id",
|
|
2927
|
+
]),
|
|
2928
|
+
...(searchParams.get("workstreamIds") ??
|
|
2929
|
+
searchParams.get("workstream_ids") ??
|
|
2930
|
+
searchParams.get("workstreamId") ??
|
|
2931
|
+
searchParams.get("workstream_id") ??
|
|
2932
|
+
"")
|
|
2933
|
+
.split(",")
|
|
2934
|
+
.map((entry) => entry.trim())
|
|
2935
|
+
.filter(Boolean),
|
|
2936
|
+
]);
|
|
2937
|
+
const allowedWorkstreamIds = workstreamFilter.length > 0 ? workstreamFilter : null;
|
|
2938
|
+
const now = new Date().toISOString();
|
|
2939
|
+
const existing = autoContinueRuns.get(initiativeId) ?? null;
|
|
2940
|
+
const run = existing ??
|
|
2941
|
+
{
|
|
2942
|
+
initiativeId,
|
|
2943
|
+
agentId,
|
|
2944
|
+
includeVerification: false,
|
|
2945
|
+
allowedWorkstreamIds: null,
|
|
2946
|
+
tokenBudget: defaultAutoContinueTokenBudget(),
|
|
2947
|
+
tokensUsed: 0,
|
|
2948
|
+
status: "running",
|
|
2949
|
+
stopReason: null,
|
|
2950
|
+
stopRequested: false,
|
|
2951
|
+
startedAt: now,
|
|
2952
|
+
stoppedAt: null,
|
|
2953
|
+
updatedAt: now,
|
|
2954
|
+
lastError: null,
|
|
2955
|
+
lastTaskId: null,
|
|
2956
|
+
lastRunId: null,
|
|
2957
|
+
activeTaskId: null,
|
|
2958
|
+
activeRunId: null,
|
|
2959
|
+
activeTaskTokenEstimate: null,
|
|
2960
|
+
};
|
|
2961
|
+
run.agentId = agentId;
|
|
2962
|
+
run.includeVerification = includeVerification;
|
|
2963
|
+
run.allowedWorkstreamIds = allowedWorkstreamIds;
|
|
2964
|
+
run.tokenBudget = normalizeTokenBudget(tokenBudget, run.tokenBudget || defaultAutoContinueTokenBudget());
|
|
2965
|
+
run.status = "running";
|
|
2966
|
+
run.stopReason = null;
|
|
2967
|
+
run.stopRequested = false;
|
|
2968
|
+
run.startedAt = now;
|
|
2969
|
+
run.stoppedAt = null;
|
|
2970
|
+
run.updatedAt = now;
|
|
2971
|
+
run.lastError = null;
|
|
2972
|
+
autoContinueRuns.set(initiativeId, run);
|
|
2973
|
+
try {
|
|
2974
|
+
await client.updateEntity("initiative", initiativeId, { status: "active" });
|
|
2975
|
+
}
|
|
2976
|
+
catch {
|
|
2977
|
+
// best effort
|
|
2978
|
+
}
|
|
2979
|
+
try {
|
|
2980
|
+
await updateInitiativeAutoContinueState({ initiativeId, run });
|
|
2981
|
+
}
|
|
2982
|
+
catch {
|
|
2983
|
+
// best effort
|
|
2984
|
+
}
|
|
2985
|
+
sendJson(res, 200, { ok: true, run });
|
|
2986
|
+
}
|
|
2987
|
+
catch (err) {
|
|
2988
|
+
sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
|
|
2989
|
+
}
|
|
2990
|
+
return true;
|
|
2991
|
+
}
|
|
2992
|
+
if (method === "POST" && isMissionControlAutoContinueStopRoute) {
|
|
2993
|
+
try {
|
|
2994
|
+
const payload = await parseJsonRequest(req);
|
|
2995
|
+
const initiativeId = (pickString(payload, ["initiativeId", "initiative_id"]) ??
|
|
2996
|
+
searchParams.get("initiativeId") ??
|
|
2997
|
+
searchParams.get("initiative_id") ??
|
|
2998
|
+
"")
|
|
2999
|
+
.trim();
|
|
3000
|
+
if (!initiativeId) {
|
|
3001
|
+
sendJson(res, 400, { ok: false, error: "initiativeId is required" });
|
|
3002
|
+
return true;
|
|
3003
|
+
}
|
|
3004
|
+
const run = autoContinueRuns.get(initiativeId) ?? null;
|
|
3005
|
+
if (!run) {
|
|
3006
|
+
sendJson(res, 404, { ok: false, error: "No auto-continue run found" });
|
|
3007
|
+
return true;
|
|
3008
|
+
}
|
|
3009
|
+
const now = new Date().toISOString();
|
|
3010
|
+
run.stopRequested = true;
|
|
3011
|
+
run.status = run.activeRunId ? "stopping" : "stopped";
|
|
3012
|
+
run.updatedAt = now;
|
|
3013
|
+
if (!run.activeRunId) {
|
|
3014
|
+
await stopAutoContinueRun({ run, reason: "stopped" });
|
|
3015
|
+
}
|
|
3016
|
+
else {
|
|
3017
|
+
try {
|
|
3018
|
+
await updateInitiativeAutoContinueState({ initiativeId, run });
|
|
3019
|
+
}
|
|
3020
|
+
catch {
|
|
3021
|
+
// best effort
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
sendJson(res, 200, { ok: true, run });
|
|
3025
|
+
}
|
|
3026
|
+
catch (err) {
|
|
3027
|
+
sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
|
|
3028
|
+
}
|
|
3029
|
+
return true;
|
|
3030
|
+
}
|
|
241
3031
|
if (method === "POST" &&
|
|
242
3032
|
(route === "live/decisions/approve" || decisionApproveMatch)) {
|
|
243
3033
|
try {
|
|
244
|
-
const payload =
|
|
3034
|
+
const payload = await parseJsonRequest(req);
|
|
245
3035
|
const action = payload.action === "reject" ? "reject" : "approve";
|
|
246
3036
|
const note = typeof payload.note === "string" && payload.note.trim().length > 0
|
|
247
3037
|
? payload.note.trim()
|
|
@@ -277,14 +3067,14 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
277
3067
|
}
|
|
278
3068
|
catch (err) {
|
|
279
3069
|
sendJson(res, 500, {
|
|
280
|
-
error:
|
|
3070
|
+
error: safeErrorMessage(err),
|
|
281
3071
|
});
|
|
282
3072
|
}
|
|
283
3073
|
return true;
|
|
284
3074
|
}
|
|
285
3075
|
if (method === "POST" && isDelegationPreflight) {
|
|
286
3076
|
try {
|
|
287
|
-
const payload =
|
|
3077
|
+
const payload = await parseJsonRequest(req);
|
|
288
3078
|
const intent = pickString(payload, ["intent"]);
|
|
289
3079
|
if (!intent) {
|
|
290
3080
|
sendJson(res, 400, { error: "intent is required" });
|
|
@@ -303,7 +3093,40 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
303
3093
|
}
|
|
304
3094
|
catch (err) {
|
|
305
3095
|
sendJson(res, 500, {
|
|
306
|
-
error:
|
|
3096
|
+
error: safeErrorMessage(err),
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
return true;
|
|
3100
|
+
}
|
|
3101
|
+
if (method === "POST" && isMissionControlAutoAssignmentRoute) {
|
|
3102
|
+
try {
|
|
3103
|
+
const payload = await parseJsonRequest(req);
|
|
3104
|
+
const entityId = pickString(payload, ["entity_id", "entityId"]);
|
|
3105
|
+
const entityType = pickString(payload, ["entity_type", "entityType"]);
|
|
3106
|
+
const initiativeId = pickString(payload, ["initiative_id", "initiativeId"]) ?? null;
|
|
3107
|
+
const title = pickString(payload, ["title", "name"]) ?? "Untitled";
|
|
3108
|
+
const summary = pickString(payload, ["summary", "description", "context"]) ?? null;
|
|
3109
|
+
if (!entityId || !entityType) {
|
|
3110
|
+
sendJson(res, 400, {
|
|
3111
|
+
ok: false,
|
|
3112
|
+
error: "entity_id and entity_type are required.",
|
|
3113
|
+
});
|
|
3114
|
+
return true;
|
|
3115
|
+
}
|
|
3116
|
+
const assignment = await resolveAutoAssignments({
|
|
3117
|
+
client,
|
|
3118
|
+
entityId,
|
|
3119
|
+
entityType,
|
|
3120
|
+
initiativeId,
|
|
3121
|
+
title,
|
|
3122
|
+
summary,
|
|
3123
|
+
});
|
|
3124
|
+
sendJson(res, 200, assignment);
|
|
3125
|
+
}
|
|
3126
|
+
catch (err) {
|
|
3127
|
+
sendJson(res, 500, {
|
|
3128
|
+
ok: false,
|
|
3129
|
+
error: safeErrorMessage(err),
|
|
307
3130
|
});
|
|
308
3131
|
}
|
|
309
3132
|
return true;
|
|
@@ -311,7 +3134,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
311
3134
|
if (runCheckpointsMatch && method === "POST") {
|
|
312
3135
|
try {
|
|
313
3136
|
const runId = decodeURIComponent(runCheckpointsMatch[1]);
|
|
314
|
-
const payload =
|
|
3137
|
+
const payload = await parseJsonRequest(req);
|
|
315
3138
|
const reason = pickString(payload, ["reason"]) ?? undefined;
|
|
316
3139
|
const rawPayload = payload.payload;
|
|
317
3140
|
const checkpointPayload = rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
|
|
@@ -325,7 +3148,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
325
3148
|
}
|
|
326
3149
|
catch (err) {
|
|
327
3150
|
sendJson(res, 500, {
|
|
328
|
-
error:
|
|
3151
|
+
error: safeErrorMessage(err),
|
|
329
3152
|
});
|
|
330
3153
|
}
|
|
331
3154
|
return true;
|
|
@@ -334,7 +3157,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
334
3157
|
try {
|
|
335
3158
|
const runId = decodeURIComponent(runCheckpointRestoreMatch[1]);
|
|
336
3159
|
const checkpointId = decodeURIComponent(runCheckpointRestoreMatch[2]);
|
|
337
|
-
const payload =
|
|
3160
|
+
const payload = await parseJsonRequest(req);
|
|
338
3161
|
const reason = pickString(payload, ["reason"]) ?? undefined;
|
|
339
3162
|
const data = await client.restoreRunCheckpoint(runId, {
|
|
340
3163
|
checkpointId,
|
|
@@ -344,7 +3167,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
344
3167
|
}
|
|
345
3168
|
catch (err) {
|
|
346
3169
|
sendJson(res, 500, {
|
|
347
|
-
error:
|
|
3170
|
+
error: safeErrorMessage(err),
|
|
348
3171
|
});
|
|
349
3172
|
}
|
|
350
3173
|
return true;
|
|
@@ -353,7 +3176,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
353
3176
|
try {
|
|
354
3177
|
const runId = decodeURIComponent(runActionMatch[1]);
|
|
355
3178
|
const action = decodeURIComponent(runActionMatch[2]);
|
|
356
|
-
const payload =
|
|
3179
|
+
const payload = await parseJsonRequest(req);
|
|
357
3180
|
const checkpointId = pickString(payload, ["checkpointId", "checkpoint_id"]);
|
|
358
3181
|
const reason = pickString(payload, ["reason"]);
|
|
359
3182
|
const data = await client.runAction(runId, action, {
|
|
@@ -364,19 +3187,74 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
364
3187
|
}
|
|
365
3188
|
catch (err) {
|
|
366
3189
|
sendJson(res, 500, {
|
|
367
|
-
error:
|
|
3190
|
+
error: safeErrorMessage(err),
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
return true;
|
|
3194
|
+
}
|
|
3195
|
+
// Entity action / delete route: POST /orgx/api/entities/{type}/{id}/{action}
|
|
3196
|
+
if (entityActionMatch && method === "POST") {
|
|
3197
|
+
try {
|
|
3198
|
+
const entityType = decodeURIComponent(entityActionMatch[1]);
|
|
3199
|
+
const entityId = decodeURIComponent(entityActionMatch[2]);
|
|
3200
|
+
const entityAction = decodeURIComponent(entityActionMatch[3]);
|
|
3201
|
+
const payload = await parseJsonRequest(req);
|
|
3202
|
+
if (entityAction === "delete") {
|
|
3203
|
+
// Delete via status update
|
|
3204
|
+
const entity = await client.updateEntity(entityType, entityId, {
|
|
3205
|
+
status: "deleted",
|
|
3206
|
+
});
|
|
3207
|
+
sendJson(res, 200, { ok: true, entity });
|
|
3208
|
+
}
|
|
3209
|
+
else {
|
|
3210
|
+
// Map action to status update
|
|
3211
|
+
const statusMap = {
|
|
3212
|
+
start: "in_progress",
|
|
3213
|
+
complete: "done",
|
|
3214
|
+
block: "blocked",
|
|
3215
|
+
unblock: "in_progress",
|
|
3216
|
+
pause: "paused",
|
|
3217
|
+
resume: "active",
|
|
3218
|
+
};
|
|
3219
|
+
const newStatus = statusMap[entityAction];
|
|
3220
|
+
if (!newStatus) {
|
|
3221
|
+
sendJson(res, 400, {
|
|
3222
|
+
error: `Unknown entity action: ${entityAction}`,
|
|
3223
|
+
});
|
|
3224
|
+
return true;
|
|
3225
|
+
}
|
|
3226
|
+
const entity = await client.updateEntity(entityType, entityId, {
|
|
3227
|
+
status: newStatus,
|
|
3228
|
+
...(payload.force ? { force: true } : {}),
|
|
3229
|
+
});
|
|
3230
|
+
sendJson(res, 200, { ok: true, entity });
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
catch (err) {
|
|
3234
|
+
sendJson(res, 500, {
|
|
3235
|
+
error: safeErrorMessage(err),
|
|
368
3236
|
});
|
|
369
3237
|
}
|
|
370
3238
|
return true;
|
|
371
3239
|
}
|
|
372
3240
|
if (method !== "GET" &&
|
|
3241
|
+
method !== "HEAD" &&
|
|
373
3242
|
!(runCheckpointsMatch && method === "POST") &&
|
|
374
3243
|
!(runCheckpointRestoreMatch && method === "POST") &&
|
|
375
3244
|
!(runActionMatch && method === "POST") &&
|
|
376
3245
|
!(isDelegationPreflight && method === "POST") &&
|
|
377
|
-
!(
|
|
3246
|
+
!(isMissionControlAutoAssignmentRoute && method === "POST") &&
|
|
3247
|
+
!(isEntitiesRoute && method === "POST") &&
|
|
3248
|
+
!(isEntitiesRoute && method === "PATCH") &&
|
|
3249
|
+
!(entityActionMatch && method === "POST") &&
|
|
3250
|
+
!(isOnboardingStartRoute && method === "POST") &&
|
|
3251
|
+
!(isOnboardingManualKeyRoute && method === "POST") &&
|
|
3252
|
+
!(isOnboardingDisconnectRoute && method === "POST") &&
|
|
3253
|
+
!(isByokSettingsRoute && method === "POST") &&
|
|
3254
|
+
!(isLiveActivityHeadlineRoute && method === "POST")) {
|
|
378
3255
|
res.writeHead(405, {
|
|
379
3256
|
"Content-Type": "text/plain",
|
|
3257
|
+
...SECURITY_HEADERS,
|
|
380
3258
|
...CORS_HEADERS,
|
|
381
3259
|
});
|
|
382
3260
|
res.end("Method Not Allowed");
|
|
@@ -394,12 +3272,150 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
394
3272
|
// use null snapshot
|
|
395
3273
|
}
|
|
396
3274
|
}
|
|
397
|
-
|
|
3275
|
+
if (method === "HEAD") {
|
|
3276
|
+
// The dashboard uses a HEAD probe to determine connection state.
|
|
3277
|
+
// Mirror the GET semantics (connected vs not) via status code,
|
|
3278
|
+
// but omit a response body.
|
|
3279
|
+
res.writeHead(snapshot ? 200 : 503, {
|
|
3280
|
+
...SECURITY_HEADERS,
|
|
3281
|
+
...CORS_HEADERS,
|
|
3282
|
+
});
|
|
3283
|
+
res.end();
|
|
3284
|
+
return true;
|
|
3285
|
+
}
|
|
3286
|
+
sendJson(res, 200, formatStatus(snapshot));
|
|
3287
|
+
return true;
|
|
3288
|
+
}
|
|
3289
|
+
case "health": {
|
|
3290
|
+
const probeRemote = parseBooleanQuery(searchParams.get("probe") ?? searchParams.get("probe_remote"));
|
|
3291
|
+
try {
|
|
3292
|
+
if (diagnostics?.getHealth) {
|
|
3293
|
+
const health = await diagnostics.getHealth({ probeRemote });
|
|
3294
|
+
sendJson(res, 200, health);
|
|
3295
|
+
return true;
|
|
3296
|
+
}
|
|
3297
|
+
const outbox = await outboxAdapter.readSummary();
|
|
3298
|
+
sendJson(res, 200, {
|
|
3299
|
+
ok: true,
|
|
3300
|
+
status: "ok",
|
|
3301
|
+
generatedAt: new Date().toISOString(),
|
|
3302
|
+
checks: [],
|
|
3303
|
+
plugin: {
|
|
3304
|
+
baseUrl: config.baseUrl,
|
|
3305
|
+
},
|
|
3306
|
+
auth: {
|
|
3307
|
+
hasApiKey: Boolean(config.apiKey),
|
|
3308
|
+
},
|
|
3309
|
+
outbox: {
|
|
3310
|
+
pendingTotal: outbox.pendingTotal,
|
|
3311
|
+
pendingByQueue: outbox.pendingByQueue,
|
|
3312
|
+
oldestEventAt: outbox.oldestEventAt,
|
|
3313
|
+
newestEventAt: outbox.newestEventAt,
|
|
3314
|
+
replayStatus: "idle",
|
|
3315
|
+
lastReplayAttemptAt: null,
|
|
3316
|
+
lastReplaySuccessAt: null,
|
|
3317
|
+
lastReplayFailureAt: null,
|
|
3318
|
+
lastReplayError: null,
|
|
3319
|
+
},
|
|
3320
|
+
remote: {
|
|
3321
|
+
enabled: false,
|
|
3322
|
+
reachable: null,
|
|
3323
|
+
latencyMs: null,
|
|
3324
|
+
error: null,
|
|
3325
|
+
},
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
catch (err) {
|
|
3329
|
+
sendJson(res, 500, {
|
|
3330
|
+
error: safeErrorMessage(err),
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
398
3333
|
return true;
|
|
399
3334
|
}
|
|
400
3335
|
case "agents":
|
|
401
3336
|
sendJson(res, 200, formatAgents(getSnapshot()));
|
|
402
3337
|
return true;
|
|
3338
|
+
case "agents/catalog": {
|
|
3339
|
+
try {
|
|
3340
|
+
const [openclawAgents, localSnapshot] = await Promise.all([
|
|
3341
|
+
listOpenClawAgents(),
|
|
3342
|
+
loadLocalOpenClawSnapshot(240).catch(() => null),
|
|
3343
|
+
]);
|
|
3344
|
+
const localById = new Map();
|
|
3345
|
+
if (localSnapshot) {
|
|
3346
|
+
for (const agent of localSnapshot.agents) {
|
|
3347
|
+
localById.set(agent.id, {
|
|
3348
|
+
status: agent.status,
|
|
3349
|
+
currentTask: agent.currentTask,
|
|
3350
|
+
runId: agent.runId,
|
|
3351
|
+
startedAt: agent.startedAt,
|
|
3352
|
+
blockers: agent.blockers,
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
const contexts = readAgentContexts().agents;
|
|
3357
|
+
const runs = readAgentRuns().runs;
|
|
3358
|
+
const latestRunByAgent = new Map();
|
|
3359
|
+
for (const run of Object.values(runs)) {
|
|
3360
|
+
if (!run || typeof run !== "object")
|
|
3361
|
+
continue;
|
|
3362
|
+
const agentId = typeof run.agentId === "string" ? run.agentId.trim() : "";
|
|
3363
|
+
if (!agentId)
|
|
3364
|
+
continue;
|
|
3365
|
+
const existing = latestRunByAgent.get(agentId);
|
|
3366
|
+
const nextTs = Date.parse(run.startedAt ?? "");
|
|
3367
|
+
const existingTs = existing ? Date.parse(existing.startedAt ?? "") : 0;
|
|
3368
|
+
// Prefer latest running record; fall back to latest overall if none running.
|
|
3369
|
+
if (!existing) {
|
|
3370
|
+
latestRunByAgent.set(agentId, run);
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
const existingRunning = existing.status === "running";
|
|
3374
|
+
const nextRunning = run.status === "running";
|
|
3375
|
+
if (nextRunning && !existingRunning) {
|
|
3376
|
+
latestRunByAgent.set(agentId, run);
|
|
3377
|
+
continue;
|
|
3378
|
+
}
|
|
3379
|
+
if (nextRunning === existingRunning && nextTs > existingTs) {
|
|
3380
|
+
latestRunByAgent.set(agentId, run);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
const agents = openclawAgents.map((entry) => {
|
|
3384
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
3385
|
+
const name = typeof entry.name === "string" && entry.name.trim().length > 0
|
|
3386
|
+
? entry.name.trim()
|
|
3387
|
+
: id || "unknown";
|
|
3388
|
+
const local = id ? localById.get(id) ?? null : null;
|
|
3389
|
+
const context = id ? contexts[id] ?? null : null;
|
|
3390
|
+
const runFromSession = id && local?.runId ? runs[local.runId] ?? null : null;
|
|
3391
|
+
const run = runFromSession ?? (id ? latestRunByAgent.get(id) ?? null : null);
|
|
3392
|
+
return {
|
|
3393
|
+
id,
|
|
3394
|
+
name,
|
|
3395
|
+
workspace: typeof entry.workspace === "string" ? entry.workspace : null,
|
|
3396
|
+
model: typeof entry.model === "string" ? entry.model : null,
|
|
3397
|
+
isDefault: Boolean(entry.isDefault),
|
|
3398
|
+
status: local?.status ?? null,
|
|
3399
|
+
currentTask: local?.currentTask ?? null,
|
|
3400
|
+
runId: local?.runId ?? null,
|
|
3401
|
+
startedAt: local?.startedAt ?? null,
|
|
3402
|
+
blockers: local?.blockers ?? [],
|
|
3403
|
+
context,
|
|
3404
|
+
run,
|
|
3405
|
+
};
|
|
3406
|
+
});
|
|
3407
|
+
sendJson(res, 200, {
|
|
3408
|
+
generatedAt: new Date().toISOString(),
|
|
3409
|
+
agents,
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
catch (err) {
|
|
3413
|
+
sendJson(res, 500, {
|
|
3414
|
+
error: safeErrorMessage(err),
|
|
3415
|
+
});
|
|
3416
|
+
}
|
|
3417
|
+
return true;
|
|
3418
|
+
}
|
|
403
3419
|
case "activity":
|
|
404
3420
|
sendJson(res, 200, formatActivity(getSnapshot()));
|
|
405
3421
|
return true;
|
|
@@ -407,12 +3423,255 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
407
3423
|
sendJson(res, 200, formatInitiatives(getSnapshot()));
|
|
408
3424
|
return true;
|
|
409
3425
|
case "onboarding":
|
|
410
|
-
sendJson(res, 200, getOnboardingState(
|
|
3426
|
+
sendJson(res, 200, getOnboardingState(await onboarding.getStatus()));
|
|
3427
|
+
return true;
|
|
3428
|
+
case "mission-control/auto-continue/status": {
|
|
3429
|
+
const initiativeId = searchParams.get("initiative_id") ??
|
|
3430
|
+
searchParams.get("initiativeId") ??
|
|
3431
|
+
"";
|
|
3432
|
+
const id = initiativeId.trim();
|
|
3433
|
+
if (!id) {
|
|
3434
|
+
sendJson(res, 400, {
|
|
3435
|
+
ok: false,
|
|
3436
|
+
error: "Query parameter 'initiative_id' is required.",
|
|
3437
|
+
});
|
|
3438
|
+
return true;
|
|
3439
|
+
}
|
|
3440
|
+
const run = autoContinueRuns.get(id) ?? null;
|
|
3441
|
+
sendJson(res, 200, {
|
|
3442
|
+
ok: true,
|
|
3443
|
+
initiativeId: id,
|
|
3444
|
+
run,
|
|
3445
|
+
defaults: {
|
|
3446
|
+
tokenBudget: defaultAutoContinueTokenBudget(),
|
|
3447
|
+
tickMs: AUTO_CONTINUE_TICK_MS,
|
|
3448
|
+
},
|
|
3449
|
+
});
|
|
3450
|
+
return true;
|
|
3451
|
+
}
|
|
3452
|
+
case "billing/status": {
|
|
3453
|
+
if (method !== "GET") {
|
|
3454
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
3455
|
+
return true;
|
|
3456
|
+
}
|
|
3457
|
+
try {
|
|
3458
|
+
const status = await client.getBillingStatus();
|
|
3459
|
+
sendJson(res, 200, { ok: true, data: status });
|
|
3460
|
+
}
|
|
3461
|
+
catch (err) {
|
|
3462
|
+
sendJson(res, 200, { ok: false, error: safeErrorMessage(err) });
|
|
3463
|
+
}
|
|
3464
|
+
return true;
|
|
3465
|
+
}
|
|
3466
|
+
case "billing/checkout": {
|
|
3467
|
+
if (method !== "POST") {
|
|
3468
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
3469
|
+
return true;
|
|
3470
|
+
}
|
|
3471
|
+
const basePricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
|
|
3472
|
+
try {
|
|
3473
|
+
const payload = await parseJsonRequest(req);
|
|
3474
|
+
const planIdRaw = (pickString(payload, ["planId", "plan_id", "plan"]) ?? "starter").trim().toLowerCase();
|
|
3475
|
+
const billingCycleRaw = (pickString(payload, ["billingCycle", "billing_cycle"]) ?? "monthly").trim().toLowerCase();
|
|
3476
|
+
const planId = planIdRaw === "team" || planIdRaw === "enterprise" ? planIdRaw : "starter";
|
|
3477
|
+
const billingCycle = billingCycleRaw === "annual" ? "annual" : "monthly";
|
|
3478
|
+
const result = await client.createBillingCheckout({
|
|
3479
|
+
planId,
|
|
3480
|
+
billingCycle,
|
|
3481
|
+
});
|
|
3482
|
+
const url = result?.url ?? result?.checkout_url ?? null;
|
|
3483
|
+
sendJson(res, 200, { ok: true, data: { url: url ?? basePricingUrl } });
|
|
3484
|
+
}
|
|
3485
|
+
catch (err) {
|
|
3486
|
+
// If the remote billing endpoints are not deployed yet, degrade gracefully.
|
|
3487
|
+
sendJson(res, 200, { ok: true, data: { url: basePricingUrl } });
|
|
3488
|
+
}
|
|
3489
|
+
return true;
|
|
3490
|
+
}
|
|
3491
|
+
case "billing/portal": {
|
|
3492
|
+
if (method !== "POST") {
|
|
3493
|
+
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
3494
|
+
return true;
|
|
3495
|
+
}
|
|
3496
|
+
const basePricingUrl = `${client.getBaseUrl().replace(/\/+$/, "")}/pricing`;
|
|
3497
|
+
try {
|
|
3498
|
+
const result = await client.createBillingPortal();
|
|
3499
|
+
const url = result?.url ?? null;
|
|
3500
|
+
sendJson(res, 200, { ok: true, data: { url: url ?? basePricingUrl } });
|
|
3501
|
+
}
|
|
3502
|
+
catch (err) {
|
|
3503
|
+
sendJson(res, 200, { ok: true, data: { url: basePricingUrl } });
|
|
3504
|
+
}
|
|
3505
|
+
return true;
|
|
3506
|
+
}
|
|
3507
|
+
case "settings/byok": {
|
|
3508
|
+
const stored = readByokKeys();
|
|
3509
|
+
const effectiveOpenai = stored?.openaiApiKey ?? process.env.OPENAI_API_KEY ?? null;
|
|
3510
|
+
const effectiveAnthropic = stored?.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? null;
|
|
3511
|
+
const effectiveOpenrouter = stored?.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null;
|
|
3512
|
+
const toProvider = (input) => {
|
|
3513
|
+
const hasStored = typeof input.storedValue === "string" && input.storedValue.trim().length > 0;
|
|
3514
|
+
const hasEnv = typeof input.envValue === "string" && input.envValue.trim().length > 0;
|
|
3515
|
+
const source = hasStored ? "stored" : hasEnv ? "env" : "none";
|
|
3516
|
+
return {
|
|
3517
|
+
configured: Boolean(input.effective && input.effective.trim().length > 0),
|
|
3518
|
+
source,
|
|
3519
|
+
masked: maskSecret(input.effective),
|
|
3520
|
+
};
|
|
3521
|
+
};
|
|
3522
|
+
if (method === "POST") {
|
|
3523
|
+
try {
|
|
3524
|
+
const payload = await parseJsonRequest(req);
|
|
3525
|
+
const updates = {};
|
|
3526
|
+
const setIfPresent = (key, aliases) => {
|
|
3527
|
+
for (const alias of aliases) {
|
|
3528
|
+
if (!Object.prototype.hasOwnProperty.call(payload, alias))
|
|
3529
|
+
continue;
|
|
3530
|
+
const raw = payload[alias];
|
|
3531
|
+
if (raw === null) {
|
|
3532
|
+
updates[key] = null;
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
if (typeof raw === "string") {
|
|
3536
|
+
updates[key] = raw;
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
};
|
|
3541
|
+
setIfPresent("openaiApiKey", ["openaiApiKey", "openai_api_key", "openaiKey", "openai_key"]);
|
|
3542
|
+
setIfPresent("anthropicApiKey", [
|
|
3543
|
+
"anthropicApiKey",
|
|
3544
|
+
"anthropic_api_key",
|
|
3545
|
+
"anthropicKey",
|
|
3546
|
+
"anthropic_key",
|
|
3547
|
+
]);
|
|
3548
|
+
setIfPresent("openrouterApiKey", [
|
|
3549
|
+
"openrouterApiKey",
|
|
3550
|
+
"openrouter_api_key",
|
|
3551
|
+
"openrouterKey",
|
|
3552
|
+
"openrouter_key",
|
|
3553
|
+
]);
|
|
3554
|
+
const saved = writeByokKeys(updates);
|
|
3555
|
+
const nextEffectiveOpenai = saved.openaiApiKey ?? process.env.OPENAI_API_KEY ?? null;
|
|
3556
|
+
const nextEffectiveAnthropic = saved.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY ?? null;
|
|
3557
|
+
const nextEffectiveOpenrouter = saved.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null;
|
|
3558
|
+
sendJson(res, 200, {
|
|
3559
|
+
ok: true,
|
|
3560
|
+
updatedAt: saved.updatedAt,
|
|
3561
|
+
providers: {
|
|
3562
|
+
openai: toProvider({
|
|
3563
|
+
storedValue: saved.openaiApiKey,
|
|
3564
|
+
envValue: process.env.OPENAI_API_KEY,
|
|
3565
|
+
effective: nextEffectiveOpenai,
|
|
3566
|
+
}),
|
|
3567
|
+
anthropic: toProvider({
|
|
3568
|
+
storedValue: saved.anthropicApiKey,
|
|
3569
|
+
envValue: process.env.ANTHROPIC_API_KEY,
|
|
3570
|
+
effective: nextEffectiveAnthropic,
|
|
3571
|
+
}),
|
|
3572
|
+
openrouter: toProvider({
|
|
3573
|
+
storedValue: saved.openrouterApiKey,
|
|
3574
|
+
envValue: process.env.OPENROUTER_API_KEY,
|
|
3575
|
+
effective: nextEffectiveOpenrouter,
|
|
3576
|
+
}),
|
|
3577
|
+
},
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
catch (err) {
|
|
3581
|
+
sendJson(res, 500, { ok: false, error: safeErrorMessage(err) });
|
|
3582
|
+
}
|
|
3583
|
+
return true;
|
|
3584
|
+
}
|
|
3585
|
+
sendJson(res, 200, {
|
|
3586
|
+
ok: true,
|
|
3587
|
+
updatedAt: stored?.updatedAt ?? null,
|
|
3588
|
+
providers: {
|
|
3589
|
+
openai: toProvider({
|
|
3590
|
+
storedValue: stored?.openaiApiKey,
|
|
3591
|
+
envValue: process.env.OPENAI_API_KEY,
|
|
3592
|
+
effective: effectiveOpenai,
|
|
3593
|
+
}),
|
|
3594
|
+
anthropic: toProvider({
|
|
3595
|
+
storedValue: stored?.anthropicApiKey,
|
|
3596
|
+
envValue: process.env.ANTHROPIC_API_KEY,
|
|
3597
|
+
effective: effectiveAnthropic,
|
|
3598
|
+
}),
|
|
3599
|
+
openrouter: toProvider({
|
|
3600
|
+
storedValue: stored?.openrouterApiKey,
|
|
3601
|
+
envValue: process.env.OPENROUTER_API_KEY,
|
|
3602
|
+
effective: effectiveOpenrouter,
|
|
3603
|
+
}),
|
|
3604
|
+
},
|
|
3605
|
+
});
|
|
3606
|
+
return true;
|
|
3607
|
+
}
|
|
3608
|
+
case "settings/byok/health": {
|
|
3609
|
+
let agentId = searchParams.get("agentId") ??
|
|
3610
|
+
searchParams.get("agent_id") ??
|
|
3611
|
+
"";
|
|
3612
|
+
agentId = agentId.trim();
|
|
3613
|
+
if (!agentId) {
|
|
3614
|
+
try {
|
|
3615
|
+
const agents = await listOpenClawAgents();
|
|
3616
|
+
const defaultAgent = agents.find((entry) => Boolean(entry.isDefault)) ?? agents[0] ?? null;
|
|
3617
|
+
const candidate = defaultAgent && typeof defaultAgent.id === "string" ? defaultAgent.id.trim() : "";
|
|
3618
|
+
if (candidate)
|
|
3619
|
+
agentId = candidate;
|
|
3620
|
+
}
|
|
3621
|
+
catch {
|
|
3622
|
+
// ignore
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
if (!agentId)
|
|
3626
|
+
agentId = "main";
|
|
3627
|
+
const providers = {};
|
|
3628
|
+
for (const provider of ["openai", "anthropic", "openrouter"]) {
|
|
3629
|
+
try {
|
|
3630
|
+
const models = await listOpenClawProviderModels({ agentId, provider });
|
|
3631
|
+
providers[provider] = {
|
|
3632
|
+
ok: true,
|
|
3633
|
+
modelCount: models.length,
|
|
3634
|
+
sample: models.slice(0, 4).map((model) => model.key),
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
catch (err) {
|
|
3638
|
+
providers[provider] = {
|
|
3639
|
+
ok: false,
|
|
3640
|
+
error: safeErrorMessage(err),
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
sendJson(res, 200, {
|
|
3645
|
+
ok: true,
|
|
3646
|
+
agentId,
|
|
3647
|
+
providers,
|
|
3648
|
+
});
|
|
3649
|
+
return true;
|
|
3650
|
+
}
|
|
3651
|
+
case "mission-control/graph": {
|
|
3652
|
+
const initiativeId = searchParams.get("initiative_id") ??
|
|
3653
|
+
searchParams.get("initiativeId");
|
|
3654
|
+
if (!initiativeId || initiativeId.trim().length === 0) {
|
|
3655
|
+
sendJson(res, 400, {
|
|
3656
|
+
error: "Query parameter 'initiative_id' is required.",
|
|
3657
|
+
});
|
|
3658
|
+
return true;
|
|
3659
|
+
}
|
|
3660
|
+
try {
|
|
3661
|
+
const graph = await buildMissionControlGraph(client, initiativeId.trim());
|
|
3662
|
+
sendJson(res, 200, graph);
|
|
3663
|
+
}
|
|
3664
|
+
catch (err) {
|
|
3665
|
+
sendJson(res, 500, {
|
|
3666
|
+
error: safeErrorMessage(err),
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
411
3669
|
return true;
|
|
3670
|
+
}
|
|
412
3671
|
case "entities": {
|
|
413
3672
|
if (method === "POST") {
|
|
414
3673
|
try {
|
|
415
|
-
const payload =
|
|
3674
|
+
const payload = await parseJsonRequest(req);
|
|
416
3675
|
const type = pickString(payload, ["type"]);
|
|
417
3676
|
const title = pickString(payload, ["title", "name"]);
|
|
418
3677
|
if (!type || !title) {
|
|
@@ -421,14 +3680,61 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
421
3680
|
});
|
|
422
3681
|
return true;
|
|
423
3682
|
}
|
|
424
|
-
const data = { ...payload, title };
|
|
3683
|
+
const data = normalizeEntityMutationPayload({ ...payload, title });
|
|
425
3684
|
delete data.type;
|
|
426
|
-
|
|
427
|
-
|
|
3685
|
+
let entity = await client.createEntity(type, data);
|
|
3686
|
+
let autoAssignment = null;
|
|
3687
|
+
if (type === "initiative" || type === "workstream") {
|
|
3688
|
+
const entityRecord = entity;
|
|
3689
|
+
autoAssignment = await resolveAutoAssignments({
|
|
3690
|
+
client,
|
|
3691
|
+
entityId: String(entityRecord.id ?? ""),
|
|
3692
|
+
entityType: type,
|
|
3693
|
+
initiativeId: type === "initiative"
|
|
3694
|
+
? String(entityRecord.id ?? "")
|
|
3695
|
+
: pickString(data, ["initiative_id", "initiativeId"]),
|
|
3696
|
+
title: pickString(entityRecord, ["title", "name"]) ??
|
|
3697
|
+
title ??
|
|
3698
|
+
"Untitled",
|
|
3699
|
+
summary: pickString(entityRecord, [
|
|
3700
|
+
"summary",
|
|
3701
|
+
"description",
|
|
3702
|
+
"context",
|
|
3703
|
+
]) ?? null,
|
|
3704
|
+
});
|
|
3705
|
+
if (autoAssignment.updated_entity) {
|
|
3706
|
+
entity = autoAssignment.updated_entity;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
sendJson(res, 201, { ok: true, entity, auto_assignment: autoAssignment });
|
|
3710
|
+
}
|
|
3711
|
+
catch (err) {
|
|
3712
|
+
sendJson(res, 500, {
|
|
3713
|
+
error: safeErrorMessage(err),
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
3716
|
+
return true;
|
|
3717
|
+
}
|
|
3718
|
+
if (method === "PATCH") {
|
|
3719
|
+
try {
|
|
3720
|
+
const payload = await parseJsonRequest(req);
|
|
3721
|
+
const type = pickString(payload, ["type"]);
|
|
3722
|
+
const id = pickString(payload, ["id"]);
|
|
3723
|
+
if (!type || !id) {
|
|
3724
|
+
sendJson(res, 400, {
|
|
3725
|
+
error: "Both 'type' and 'id' are required for PATCH.",
|
|
3726
|
+
});
|
|
3727
|
+
return true;
|
|
3728
|
+
}
|
|
3729
|
+
const updates = { ...payload };
|
|
3730
|
+
delete updates.type;
|
|
3731
|
+
delete updates.id;
|
|
3732
|
+
const entity = await client.updateEntity(type, id, normalizeEntityMutationPayload(updates));
|
|
3733
|
+
sendJson(res, 200, { ok: true, entity });
|
|
428
3734
|
}
|
|
429
3735
|
catch (err) {
|
|
430
3736
|
sendJson(res, 500, {
|
|
431
|
-
error:
|
|
3737
|
+
error: safeErrorMessage(err),
|
|
432
3738
|
});
|
|
433
3739
|
}
|
|
434
3740
|
return true;
|
|
@@ -442,22 +3748,288 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
442
3748
|
return true;
|
|
443
3749
|
}
|
|
444
3750
|
const status = searchParams.get("status") ?? undefined;
|
|
3751
|
+
const initiativeId = searchParams.get("initiative_id") ?? undefined;
|
|
445
3752
|
const limit = searchParams.get("limit")
|
|
446
3753
|
? Number(searchParams.get("limit"))
|
|
447
3754
|
: undefined;
|
|
448
3755
|
const data = await client.listEntities(type, {
|
|
449
3756
|
status,
|
|
3757
|
+
initiative_id: initiativeId,
|
|
450
3758
|
limit: Number.isFinite(limit) ? limit : undefined,
|
|
451
3759
|
});
|
|
452
3760
|
sendJson(res, 200, data);
|
|
453
3761
|
}
|
|
454
3762
|
catch (err) {
|
|
455
3763
|
sendJson(res, 500, {
|
|
456
|
-
error:
|
|
3764
|
+
error: safeErrorMessage(err),
|
|
457
3765
|
});
|
|
458
3766
|
}
|
|
459
3767
|
return true;
|
|
460
3768
|
}
|
|
3769
|
+
case "dashboard-bundle":
|
|
3770
|
+
case "live/snapshot": {
|
|
3771
|
+
const sessionsLimit = parsePositiveInt(searchParams.get("sessionsLimit") ?? searchParams.get("sessions_limit"), 320);
|
|
3772
|
+
const activityLimit = parsePositiveInt(searchParams.get("activityLimit") ?? searchParams.get("activity_limit"), 600);
|
|
3773
|
+
const decisionsLimit = parsePositiveInt(searchParams.get("decisionsLimit") ?? searchParams.get("decisions_limit"), 120);
|
|
3774
|
+
const initiative = searchParams.get("initiative");
|
|
3775
|
+
const run = searchParams.get("run");
|
|
3776
|
+
const since = searchParams.get("since");
|
|
3777
|
+
const decisionStatus = searchParams.get("status") ?? "pending";
|
|
3778
|
+
const includeIdleRaw = searchParams.get("include_idle");
|
|
3779
|
+
const includeIdle = includeIdleRaw === null ? undefined : includeIdleRaw !== "false";
|
|
3780
|
+
const degraded = [];
|
|
3781
|
+
const agentContexts = readAgentContexts().agents;
|
|
3782
|
+
const scopedAgentIds = getScopedAgentIds(agentContexts);
|
|
3783
|
+
let outboxStatus = null;
|
|
3784
|
+
try {
|
|
3785
|
+
if (diagnostics?.getHealth) {
|
|
3786
|
+
const health = await diagnostics.getHealth({ probeRemote: false });
|
|
3787
|
+
if (health && typeof health === "object") {
|
|
3788
|
+
const maybeOutbox = health.outbox;
|
|
3789
|
+
if (maybeOutbox && typeof maybeOutbox === "object") {
|
|
3790
|
+
outboxStatus = maybeOutbox;
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
if (!outboxStatus) {
|
|
3795
|
+
const outbox = await outboxAdapter.readSummary();
|
|
3796
|
+
outboxStatus = {
|
|
3797
|
+
pendingTotal: outbox.pendingTotal,
|
|
3798
|
+
pendingByQueue: outbox.pendingByQueue,
|
|
3799
|
+
oldestEventAt: outbox.oldestEventAt,
|
|
3800
|
+
newestEventAt: outbox.newestEventAt,
|
|
3801
|
+
replayStatus: "idle",
|
|
3802
|
+
lastReplayAttemptAt: null,
|
|
3803
|
+
lastReplaySuccessAt: null,
|
|
3804
|
+
lastReplayFailureAt: null,
|
|
3805
|
+
lastReplayError: null,
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
catch (err) {
|
|
3810
|
+
degraded.push(`outbox status unavailable (${safeErrorMessage(err)})`);
|
|
3811
|
+
outboxStatus = {
|
|
3812
|
+
pendingTotal: 0,
|
|
3813
|
+
pendingByQueue: {},
|
|
3814
|
+
oldestEventAt: null,
|
|
3815
|
+
newestEventAt: null,
|
|
3816
|
+
replayStatus: "idle",
|
|
3817
|
+
lastReplayAttemptAt: null,
|
|
3818
|
+
lastReplaySuccessAt: null,
|
|
3819
|
+
lastReplayFailureAt: null,
|
|
3820
|
+
lastReplayError: null,
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3823
|
+
let localSnapshot = null;
|
|
3824
|
+
const ensureLocalSnapshot = async (minimumLimit) => {
|
|
3825
|
+
if (!localSnapshot || localSnapshot.sessions.length < minimumLimit) {
|
|
3826
|
+
localSnapshot = await loadLocalOpenClawSnapshot(minimumLimit);
|
|
3827
|
+
}
|
|
3828
|
+
return localSnapshot;
|
|
3829
|
+
};
|
|
3830
|
+
const settled = await Promise.allSettled([
|
|
3831
|
+
client.getLiveSessions({
|
|
3832
|
+
initiative,
|
|
3833
|
+
limit: sessionsLimit,
|
|
3834
|
+
}),
|
|
3835
|
+
client.getLiveActivity({
|
|
3836
|
+
run,
|
|
3837
|
+
since,
|
|
3838
|
+
limit: activityLimit,
|
|
3839
|
+
}),
|
|
3840
|
+
client.getHandoffs(),
|
|
3841
|
+
client.getLiveDecisions({
|
|
3842
|
+
status: decisionStatus,
|
|
3843
|
+
limit: decisionsLimit,
|
|
3844
|
+
}),
|
|
3845
|
+
client.getLiveAgents({
|
|
3846
|
+
initiative,
|
|
3847
|
+
includeIdle,
|
|
3848
|
+
}),
|
|
3849
|
+
]);
|
|
3850
|
+
// sessions
|
|
3851
|
+
let sessions = {
|
|
3852
|
+
nodes: [],
|
|
3853
|
+
edges: [],
|
|
3854
|
+
groups: [],
|
|
3855
|
+
};
|
|
3856
|
+
const sessionsResult = settled[0];
|
|
3857
|
+
if (sessionsResult.status === "fulfilled") {
|
|
3858
|
+
sessions = sessionsResult.value;
|
|
3859
|
+
}
|
|
3860
|
+
else {
|
|
3861
|
+
degraded.push(`sessions unavailable (${safeErrorMessage(sessionsResult.reason)})`);
|
|
3862
|
+
try {
|
|
3863
|
+
let local = toLocalSessionTree(await ensureLocalSnapshot(Math.max(sessionsLimit, 200)), sessionsLimit);
|
|
3864
|
+
local = applyAgentContextsToSessionTree(local, agentContexts);
|
|
3865
|
+
if (initiative && initiative.trim().length > 0) {
|
|
3866
|
+
const filteredNodes = local.nodes.filter((node) => node.initiativeId === initiative || node.groupId === initiative);
|
|
3867
|
+
const filteredIds = new Set(filteredNodes.map((node) => node.id));
|
|
3868
|
+
const filteredGroupIds = new Set(filteredNodes.map((node) => node.groupId));
|
|
3869
|
+
local = {
|
|
3870
|
+
nodes: filteredNodes,
|
|
3871
|
+
edges: local.edges.filter((edge) => filteredIds.has(edge.parentId) && filteredIds.has(edge.childId)),
|
|
3872
|
+
groups: local.groups.filter((group) => filteredGroupIds.has(group.id)),
|
|
3873
|
+
};
|
|
3874
|
+
}
|
|
3875
|
+
sessions = local;
|
|
3876
|
+
}
|
|
3877
|
+
catch (localErr) {
|
|
3878
|
+
degraded.push(`sessions local fallback failed (${safeErrorMessage(localErr)})`);
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
// activity
|
|
3882
|
+
let activity = [];
|
|
3883
|
+
const activityResult = settled[1];
|
|
3884
|
+
if (activityResult.status === "fulfilled") {
|
|
3885
|
+
activity = Array.isArray(activityResult.value.activities)
|
|
3886
|
+
? activityResult.value.activities
|
|
3887
|
+
: [];
|
|
3888
|
+
}
|
|
3889
|
+
else {
|
|
3890
|
+
degraded.push(`activity unavailable (${safeErrorMessage(activityResult.reason)})`);
|
|
3891
|
+
try {
|
|
3892
|
+
const local = await toLocalLiveActivity(await ensureLocalSnapshot(Math.max(activityLimit, 240)), Math.max(activityLimit, 240));
|
|
3893
|
+
let filtered = local.activities;
|
|
3894
|
+
if (run && run.trim().length > 0) {
|
|
3895
|
+
filtered = filtered.filter((item) => item.runId === run);
|
|
3896
|
+
}
|
|
3897
|
+
if (since && since.trim().length > 0) {
|
|
3898
|
+
const sinceEpoch = Date.parse(since);
|
|
3899
|
+
if (Number.isFinite(sinceEpoch)) {
|
|
3900
|
+
filtered = filtered.filter((item) => Date.parse(item.timestamp) >= sinceEpoch);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
filtered = applyAgentContextsToActivity(filtered, agentContexts);
|
|
3904
|
+
activity = filtered.slice(0, activityLimit);
|
|
3905
|
+
}
|
|
3906
|
+
catch (localErr) {
|
|
3907
|
+
degraded.push(`activity local fallback failed (${safeErrorMessage(localErr)})`);
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
// handoffs
|
|
3911
|
+
let handoffs = [];
|
|
3912
|
+
const handoffsResult = settled[2];
|
|
3913
|
+
if (handoffsResult.status === "fulfilled") {
|
|
3914
|
+
handoffs = Array.isArray(handoffsResult.value.handoffs)
|
|
3915
|
+
? handoffsResult.value.handoffs
|
|
3916
|
+
: [];
|
|
3917
|
+
}
|
|
3918
|
+
else {
|
|
3919
|
+
degraded.push(`handoffs unavailable (${safeErrorMessage(handoffsResult.reason)})`);
|
|
3920
|
+
}
|
|
3921
|
+
// decisions
|
|
3922
|
+
let decisions = [];
|
|
3923
|
+
const decisionsResult = settled[3];
|
|
3924
|
+
if (decisionsResult.status === "fulfilled") {
|
|
3925
|
+
decisions = decisionsResult.value.decisions
|
|
3926
|
+
.map(mapDecisionEntity)
|
|
3927
|
+
.sort((a, b) => b.waitingMinutes - a.waitingMinutes);
|
|
3928
|
+
}
|
|
3929
|
+
else {
|
|
3930
|
+
degraded.push(`decisions unavailable (${safeErrorMessage(decisionsResult.reason)})`);
|
|
3931
|
+
}
|
|
3932
|
+
// agents
|
|
3933
|
+
let agents = [];
|
|
3934
|
+
const agentsResult = settled[4];
|
|
3935
|
+
if (agentsResult.status === "fulfilled") {
|
|
3936
|
+
agents = Array.isArray(agentsResult.value.agents)
|
|
3937
|
+
? agentsResult.value.agents
|
|
3938
|
+
: [];
|
|
3939
|
+
}
|
|
3940
|
+
else {
|
|
3941
|
+
degraded.push(`agents unavailable (${safeErrorMessage(agentsResult.reason)})`);
|
|
3942
|
+
try {
|
|
3943
|
+
const local = toLocalLiveAgents(await ensureLocalSnapshot(Math.max(sessionsLimit, 240)));
|
|
3944
|
+
let localAgents = local.agents;
|
|
3945
|
+
if (initiative && initiative.trim().length > 0) {
|
|
3946
|
+
localAgents = localAgents.filter((agent) => agent.initiativeId === initiative);
|
|
3947
|
+
}
|
|
3948
|
+
if (includeIdle === false) {
|
|
3949
|
+
localAgents = localAgents.filter((agent) => agent.status !== "idle");
|
|
3950
|
+
}
|
|
3951
|
+
agents = localAgents;
|
|
3952
|
+
}
|
|
3953
|
+
catch (localErr) {
|
|
3954
|
+
degraded.push(`agents local fallback failed (${safeErrorMessage(localErr)})`);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
// Merge locally-launched OpenClaw agent sessions/activity into the snapshot so
|
|
3958
|
+
// the UI reflects one-click launches even when the cloud reporting plane is reachable.
|
|
3959
|
+
if (scopedAgentIds.size > 0) {
|
|
3960
|
+
try {
|
|
3961
|
+
const minimum = Math.max(Math.max(sessionsLimit, activityLimit), 240);
|
|
3962
|
+
const snapshot = await ensureLocalSnapshot(minimum);
|
|
3963
|
+
const scopedSnapshot = {
|
|
3964
|
+
...snapshot,
|
|
3965
|
+
sessions: snapshot.sessions.filter((session) => Boolean(session.agentId && scopedAgentIds.has(session.agentId))),
|
|
3966
|
+
agents: snapshot.agents.filter((agent) => scopedAgentIds.has(agent.id)),
|
|
3967
|
+
};
|
|
3968
|
+
// Sessions
|
|
3969
|
+
let localSessions = applyAgentContextsToSessionTree(toLocalSessionTree(scopedSnapshot, sessionsLimit), agentContexts);
|
|
3970
|
+
if (initiative && initiative.trim().length > 0) {
|
|
3971
|
+
const filteredNodes = localSessions.nodes.filter((node) => node.initiativeId === initiative || node.groupId === initiative);
|
|
3972
|
+
const filteredIds = new Set(filteredNodes.map((node) => node.id));
|
|
3973
|
+
const filteredGroupIds = new Set(filteredNodes.map((node) => node.groupId));
|
|
3974
|
+
localSessions = {
|
|
3975
|
+
nodes: filteredNodes,
|
|
3976
|
+
edges: localSessions.edges.filter((edge) => filteredIds.has(edge.parentId) && filteredIds.has(edge.childId)),
|
|
3977
|
+
groups: localSessions.groups.filter((group) => filteredGroupIds.has(group.id)),
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
sessions = mergeSessionTrees(sessions, localSessions);
|
|
3981
|
+
// Activity
|
|
3982
|
+
const localActivity = await toLocalLiveActivity(scopedSnapshot, Math.max(activityLimit, 240));
|
|
3983
|
+
let localItems = applyAgentContextsToActivity(localActivity.activities, agentContexts);
|
|
3984
|
+
if (run && run.trim().length > 0) {
|
|
3985
|
+
localItems = localItems.filter((item) => item.runId === run);
|
|
3986
|
+
}
|
|
3987
|
+
if (since && since.trim().length > 0) {
|
|
3988
|
+
const sinceEpoch = Date.parse(since);
|
|
3989
|
+
if (Number.isFinite(sinceEpoch)) {
|
|
3990
|
+
localItems = localItems.filter((item) => Date.parse(item.timestamp) >= sinceEpoch);
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
activity = mergeActivities(activity, localItems, activityLimit);
|
|
3994
|
+
}
|
|
3995
|
+
catch (err) {
|
|
3996
|
+
degraded.push(`local agent merge failed (${safeErrorMessage(err)})`);
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
// include locally buffered events so offline-generated actions are visible
|
|
4000
|
+
try {
|
|
4001
|
+
const buffered = await outboxAdapter.readAllItems();
|
|
4002
|
+
if (buffered.length > 0) {
|
|
4003
|
+
const merged = [...activity, ...buffered]
|
|
4004
|
+
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
|
|
4005
|
+
.slice(0, activityLimit);
|
|
4006
|
+
const deduped = [];
|
|
4007
|
+
const seen = new Set();
|
|
4008
|
+
for (const item of merged) {
|
|
4009
|
+
if (seen.has(item.id))
|
|
4010
|
+
continue;
|
|
4011
|
+
seen.add(item.id);
|
|
4012
|
+
deduped.push(item);
|
|
4013
|
+
}
|
|
4014
|
+
activity = deduped;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
catch (err) {
|
|
4018
|
+
degraded.push(`outbox unavailable (${safeErrorMessage(err)})`);
|
|
4019
|
+
}
|
|
4020
|
+
sendJson(res, 200, {
|
|
4021
|
+
sessions,
|
|
4022
|
+
activity,
|
|
4023
|
+
handoffs,
|
|
4024
|
+
decisions,
|
|
4025
|
+
agents,
|
|
4026
|
+
outbox: outboxStatus,
|
|
4027
|
+
generatedAt: new Date().toISOString(),
|
|
4028
|
+
degraded: degraded.length > 0 ? degraded : undefined,
|
|
4029
|
+
});
|
|
4030
|
+
return true;
|
|
4031
|
+
}
|
|
4032
|
+
// Legacy endpoints retained for backwards compatibility.
|
|
461
4033
|
case "live/sessions": {
|
|
462
4034
|
try {
|
|
463
4035
|
const initiative = searchParams.get("initiative");
|
|
@@ -471,9 +4043,32 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
471
4043
|
sendJson(res, 200, data);
|
|
472
4044
|
}
|
|
473
4045
|
catch (err) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
4046
|
+
try {
|
|
4047
|
+
const initiative = searchParams.get("initiative");
|
|
4048
|
+
const limitRaw = searchParams.get("limit")
|
|
4049
|
+
? Number(searchParams.get("limit"))
|
|
4050
|
+
: undefined;
|
|
4051
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Number(limitRaw)) : 100;
|
|
4052
|
+
let local = toLocalSessionTree(await loadLocalOpenClawSnapshot(Math.max(limit, 200)), limit);
|
|
4053
|
+
local = applyAgentContextsToSessionTree(local, readAgentContexts().agents);
|
|
4054
|
+
if (initiative && initiative.trim().length > 0) {
|
|
4055
|
+
const filteredNodes = local.nodes.filter((node) => node.initiativeId === initiative || node.groupId === initiative);
|
|
4056
|
+
const filteredIds = new Set(filteredNodes.map((node) => node.id));
|
|
4057
|
+
const filteredGroupIds = new Set(filteredNodes.map((node) => node.groupId));
|
|
4058
|
+
local = {
|
|
4059
|
+
nodes: filteredNodes,
|
|
4060
|
+
edges: local.edges.filter((edge) => filteredIds.has(edge.parentId) && filteredIds.has(edge.childId)),
|
|
4061
|
+
groups: local.groups.filter((group) => filteredGroupIds.has(group.id)),
|
|
4062
|
+
};
|
|
4063
|
+
}
|
|
4064
|
+
sendJson(res, 200, local);
|
|
4065
|
+
}
|
|
4066
|
+
catch (localErr) {
|
|
4067
|
+
sendJson(res, 500, {
|
|
4068
|
+
error: safeErrorMessage(err),
|
|
4069
|
+
localFallbackError: safeErrorMessage(localErr),
|
|
4070
|
+
});
|
|
4071
|
+
}
|
|
477
4072
|
}
|
|
478
4073
|
return true;
|
|
479
4074
|
}
|
|
@@ -491,9 +4086,103 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
491
4086
|
});
|
|
492
4087
|
sendJson(res, 200, data);
|
|
493
4088
|
}
|
|
4089
|
+
catch (err) {
|
|
4090
|
+
try {
|
|
4091
|
+
const run = searchParams.get("run");
|
|
4092
|
+
const limitRaw = searchParams.get("limit")
|
|
4093
|
+
? Number(searchParams.get("limit"))
|
|
4094
|
+
: undefined;
|
|
4095
|
+
const since = searchParams.get("since");
|
|
4096
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Number(limitRaw)) : 240;
|
|
4097
|
+
const localSnapshot = await loadLocalOpenClawSnapshot(Math.max(limit, 240));
|
|
4098
|
+
let local = await toLocalLiveActivity(localSnapshot, Math.max(limit, 240));
|
|
4099
|
+
if (run && run.trim().length > 0) {
|
|
4100
|
+
local = {
|
|
4101
|
+
activities: local.activities.filter((item) => item.runId === run),
|
|
4102
|
+
total: local.activities.filter((item) => item.runId === run).length,
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
if (since && since.trim().length > 0) {
|
|
4106
|
+
const sinceEpoch = Date.parse(since);
|
|
4107
|
+
if (Number.isFinite(sinceEpoch)) {
|
|
4108
|
+
const filtered = local.activities.filter((item) => Date.parse(item.timestamp) >= sinceEpoch);
|
|
4109
|
+
local = {
|
|
4110
|
+
activities: filtered,
|
|
4111
|
+
total: filtered.length,
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
const activitiesWithContexts = applyAgentContextsToActivity(local.activities, readAgentContexts().agents);
|
|
4116
|
+
sendJson(res, 200, {
|
|
4117
|
+
activities: activitiesWithContexts.slice(0, limit),
|
|
4118
|
+
total: local.total,
|
|
4119
|
+
});
|
|
4120
|
+
}
|
|
4121
|
+
catch (localErr) {
|
|
4122
|
+
sendJson(res, 500, {
|
|
4123
|
+
error: safeErrorMessage(err),
|
|
4124
|
+
localFallbackError: safeErrorMessage(localErr),
|
|
4125
|
+
});
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
return true;
|
|
4129
|
+
}
|
|
4130
|
+
case "live/activity/detail": {
|
|
4131
|
+
const turnId = searchParams.get("turnId") ?? searchParams.get("turn_id");
|
|
4132
|
+
const sessionKey = searchParams.get("sessionKey") ?? searchParams.get("session_key");
|
|
4133
|
+
const run = searchParams.get("run");
|
|
4134
|
+
if (!turnId || turnId.trim().length === 0) {
|
|
4135
|
+
sendJson(res, 400, { error: "turnId is required" });
|
|
4136
|
+
return true;
|
|
4137
|
+
}
|
|
4138
|
+
try {
|
|
4139
|
+
const detail = await loadLocalTurnDetail({
|
|
4140
|
+
turnId,
|
|
4141
|
+
sessionKey,
|
|
4142
|
+
runId: run,
|
|
4143
|
+
});
|
|
4144
|
+
if (!detail) {
|
|
4145
|
+
sendJson(res, 404, {
|
|
4146
|
+
error: "Turn detail unavailable",
|
|
4147
|
+
turnId,
|
|
4148
|
+
});
|
|
4149
|
+
return true;
|
|
4150
|
+
}
|
|
4151
|
+
sendJson(res, 200, { detail });
|
|
4152
|
+
}
|
|
4153
|
+
catch (err) {
|
|
4154
|
+
sendJson(res, 500, { error: safeErrorMessage(err), turnId });
|
|
4155
|
+
}
|
|
4156
|
+
return true;
|
|
4157
|
+
}
|
|
4158
|
+
case "live/activity/headline": {
|
|
4159
|
+
if (method !== "POST") {
|
|
4160
|
+
sendJson(res, 405, { error: "Use POST /orgx/api/live/activity/headline" });
|
|
4161
|
+
return true;
|
|
4162
|
+
}
|
|
4163
|
+
try {
|
|
4164
|
+
const payload = await parseJsonRequest(req);
|
|
4165
|
+
const text = pickString(payload, ["text", "summary", "detail", "content"]);
|
|
4166
|
+
if (!text) {
|
|
4167
|
+
sendJson(res, 400, { error: "text is required" });
|
|
4168
|
+
return true;
|
|
4169
|
+
}
|
|
4170
|
+
const title = pickString(payload, ["title", "name"]);
|
|
4171
|
+
const type = pickString(payload, ["type", "kind"]);
|
|
4172
|
+
const result = await summarizeActivityHeadline({
|
|
4173
|
+
text,
|
|
4174
|
+
title,
|
|
4175
|
+
type,
|
|
4176
|
+
});
|
|
4177
|
+
sendJson(res, 200, {
|
|
4178
|
+
headline: result.headline,
|
|
4179
|
+
source: result.source,
|
|
4180
|
+
model: result.model,
|
|
4181
|
+
});
|
|
4182
|
+
}
|
|
494
4183
|
catch (err) {
|
|
495
4184
|
sendJson(res, 500, {
|
|
496
|
-
error:
|
|
4185
|
+
error: safeErrorMessage(err),
|
|
497
4186
|
});
|
|
498
4187
|
}
|
|
499
4188
|
return true;
|
|
@@ -510,9 +4199,31 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
510
4199
|
sendJson(res, 200, data);
|
|
511
4200
|
}
|
|
512
4201
|
catch (err) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
4202
|
+
try {
|
|
4203
|
+
const initiative = searchParams.get("initiative");
|
|
4204
|
+
const includeIdleRaw = searchParams.get("include_idle");
|
|
4205
|
+
const includeIdle = includeIdleRaw === null ? undefined : includeIdleRaw !== "false";
|
|
4206
|
+
const localSnapshot = await loadLocalOpenClawSnapshot(240);
|
|
4207
|
+
const local = toLocalLiveAgents(localSnapshot);
|
|
4208
|
+
let agents = local.agents;
|
|
4209
|
+
if (initiative && initiative.trim().length > 0) {
|
|
4210
|
+
agents = agents.filter((agent) => agent.initiativeId === initiative);
|
|
4211
|
+
}
|
|
4212
|
+
if (includeIdle === false) {
|
|
4213
|
+
agents = agents.filter((agent) => agent.status !== "idle");
|
|
4214
|
+
}
|
|
4215
|
+
const summary = agents.reduce((acc, agent) => {
|
|
4216
|
+
acc[agent.status] = (acc[agent.status] ?? 0) + 1;
|
|
4217
|
+
return acc;
|
|
4218
|
+
}, {});
|
|
4219
|
+
sendJson(res, 200, { agents, summary });
|
|
4220
|
+
}
|
|
4221
|
+
catch (localErr) {
|
|
4222
|
+
sendJson(res, 500, {
|
|
4223
|
+
error: safeErrorMessage(err),
|
|
4224
|
+
localFallbackError: safeErrorMessage(localErr),
|
|
4225
|
+
});
|
|
4226
|
+
}
|
|
516
4227
|
}
|
|
517
4228
|
return true;
|
|
518
4229
|
}
|
|
@@ -529,9 +4240,28 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
529
4240
|
sendJson(res, 200, data);
|
|
530
4241
|
}
|
|
531
4242
|
catch (err) {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
4243
|
+
try {
|
|
4244
|
+
const id = searchParams.get("id");
|
|
4245
|
+
const limitRaw = searchParams.get("limit")
|
|
4246
|
+
? Number(searchParams.get("limit"))
|
|
4247
|
+
: undefined;
|
|
4248
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Number(limitRaw)) : 100;
|
|
4249
|
+
const local = toLocalLiveInitiatives(await loadLocalOpenClawSnapshot(240));
|
|
4250
|
+
let initiatives = local.initiatives;
|
|
4251
|
+
if (id && id.trim().length > 0) {
|
|
4252
|
+
initiatives = initiatives.filter((item) => item.id === id);
|
|
4253
|
+
}
|
|
4254
|
+
sendJson(res, 200, {
|
|
4255
|
+
initiatives: initiatives.slice(0, limit),
|
|
4256
|
+
total: initiatives.length,
|
|
4257
|
+
});
|
|
4258
|
+
}
|
|
4259
|
+
catch (localErr) {
|
|
4260
|
+
sendJson(res, 500, {
|
|
4261
|
+
error: safeErrorMessage(err),
|
|
4262
|
+
localFallbackError: safeErrorMessage(localErr),
|
|
4263
|
+
});
|
|
4264
|
+
}
|
|
535
4265
|
}
|
|
536
4266
|
return true;
|
|
537
4267
|
}
|
|
@@ -553,9 +4283,10 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
553
4283
|
total: data.total,
|
|
554
4284
|
});
|
|
555
4285
|
}
|
|
556
|
-
catch
|
|
557
|
-
sendJson(res,
|
|
558
|
-
|
|
4286
|
+
catch {
|
|
4287
|
+
sendJson(res, 200, {
|
|
4288
|
+
decisions: [],
|
|
4289
|
+
total: 0,
|
|
559
4290
|
});
|
|
560
4291
|
}
|
|
561
4292
|
return true;
|
|
@@ -565,10 +4296,8 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
565
4296
|
const data = await client.getHandoffs();
|
|
566
4297
|
sendJson(res, 200, data);
|
|
567
4298
|
}
|
|
568
|
-
catch
|
|
569
|
-
sendJson(res,
|
|
570
|
-
error: err instanceof Error ? err.message : String(err),
|
|
571
|
-
});
|
|
4299
|
+
catch {
|
|
4300
|
+
sendJson(res, 200, { handoffs: [] });
|
|
572
4301
|
}
|
|
573
4302
|
return true;
|
|
574
4303
|
}
|
|
@@ -579,16 +4308,49 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
579
4308
|
return true;
|
|
580
4309
|
}
|
|
581
4310
|
const target = `${config.baseUrl.replace(/\/+$/, "")}/api/client/live/stream${queryString ? `?${queryString}` : ""}`;
|
|
4311
|
+
const streamAbortController = new AbortController();
|
|
4312
|
+
let reader = null;
|
|
4313
|
+
let closed = false;
|
|
4314
|
+
let streamOpened = false;
|
|
4315
|
+
let idleTimer = null;
|
|
4316
|
+
const clearIdleTimer = () => {
|
|
4317
|
+
if (idleTimer) {
|
|
4318
|
+
clearTimeout(idleTimer);
|
|
4319
|
+
idleTimer = null;
|
|
4320
|
+
}
|
|
4321
|
+
};
|
|
4322
|
+
const closeStream = () => {
|
|
4323
|
+
if (closed)
|
|
4324
|
+
return;
|
|
4325
|
+
closed = true;
|
|
4326
|
+
clearIdleTimer();
|
|
4327
|
+
streamAbortController.abort();
|
|
4328
|
+
if (reader) {
|
|
4329
|
+
void reader.cancel().catch(() => undefined);
|
|
4330
|
+
}
|
|
4331
|
+
if (streamOpened && !res.writableEnded) {
|
|
4332
|
+
res.end();
|
|
4333
|
+
}
|
|
4334
|
+
};
|
|
4335
|
+
const resetIdleTimer = () => {
|
|
4336
|
+
clearIdleTimer();
|
|
4337
|
+
idleTimer = setTimeout(() => {
|
|
4338
|
+
closeStream();
|
|
4339
|
+
}, STREAM_IDLE_TIMEOUT_MS);
|
|
4340
|
+
};
|
|
582
4341
|
try {
|
|
4342
|
+
const includeUserHeader = Boolean(config.userId && config.userId.trim().length > 0) &&
|
|
4343
|
+
!isUserScopedApiKey(config.apiKey);
|
|
583
4344
|
const upstream = await fetch(target, {
|
|
584
4345
|
method: "GET",
|
|
585
4346
|
headers: {
|
|
586
4347
|
Authorization: `Bearer ${config.apiKey}`,
|
|
587
4348
|
Accept: "text/event-stream",
|
|
588
|
-
...(
|
|
4349
|
+
...(includeUserHeader
|
|
589
4350
|
? { "X-Orgx-User-Id": config.userId }
|
|
590
4351
|
: {}),
|
|
591
4352
|
},
|
|
4353
|
+
signal: streamAbortController.signal,
|
|
592
4354
|
});
|
|
593
4355
|
const contentType = upstream.headers.get("content-type")?.toLowerCase() ?? "";
|
|
594
4356
|
if (!upstream.ok || !contentType.includes("text/event-stream")) {
|
|
@@ -607,29 +4369,59 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
607
4369
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
608
4370
|
"Cache-Control": "no-cache, no-transform",
|
|
609
4371
|
Connection: "keep-alive",
|
|
4372
|
+
...SECURITY_HEADERS,
|
|
610
4373
|
...CORS_HEADERS,
|
|
611
4374
|
});
|
|
4375
|
+
streamOpened = true;
|
|
612
4376
|
if (!upstream.body) {
|
|
613
|
-
|
|
4377
|
+
closeStream();
|
|
614
4378
|
return true;
|
|
615
4379
|
}
|
|
616
|
-
|
|
4380
|
+
req.on?.("close", closeStream);
|
|
4381
|
+
req.on?.("aborted", closeStream);
|
|
4382
|
+
res.on?.("close", closeStream);
|
|
4383
|
+
res.on?.("finish", closeStream);
|
|
4384
|
+
reader = upstream.body.getReader();
|
|
4385
|
+
const streamReader = reader;
|
|
4386
|
+
resetIdleTimer();
|
|
4387
|
+
const waitForDrain = async () => {
|
|
4388
|
+
if (typeof res.once === "function") {
|
|
4389
|
+
await new Promise((resolve) => {
|
|
4390
|
+
res.once?.("drain", () => resolve());
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
};
|
|
617
4394
|
const pump = async () => {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
4395
|
+
try {
|
|
4396
|
+
while (!closed) {
|
|
4397
|
+
const { done, value } = await streamReader.read();
|
|
4398
|
+
if (done)
|
|
4399
|
+
break;
|
|
4400
|
+
if (!value || value.byteLength === 0)
|
|
4401
|
+
continue;
|
|
4402
|
+
resetIdleTimer();
|
|
4403
|
+
const accepted = write(Buffer.from(value));
|
|
4404
|
+
if (accepted === false) {
|
|
4405
|
+
await waitForDrain();
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
catch {
|
|
4410
|
+
// Swallow pump errors; client disconnects are expected.
|
|
4411
|
+
}
|
|
4412
|
+
finally {
|
|
4413
|
+
closeStream();
|
|
624
4414
|
}
|
|
625
|
-
res.end();
|
|
626
4415
|
};
|
|
627
4416
|
void pump();
|
|
628
4417
|
}
|
|
629
4418
|
catch (err) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
4419
|
+
closeStream();
|
|
4420
|
+
if (!streamOpened && !res.writableEnded) {
|
|
4421
|
+
sendJson(res, 500, {
|
|
4422
|
+
error: safeErrorMessage(err),
|
|
4423
|
+
});
|
|
4424
|
+
}
|
|
633
4425
|
}
|
|
634
4426
|
return true;
|
|
635
4427
|
}
|
|
@@ -646,7 +4438,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
646
4438
|
}
|
|
647
4439
|
catch (err) {
|
|
648
4440
|
sendJson(res, 500, {
|
|
649
|
-
error:
|
|
4441
|
+
error: safeErrorMessage(err),
|
|
650
4442
|
});
|
|
651
4443
|
}
|
|
652
4444
|
return true;
|
|
@@ -664,6 +4456,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
664
4456
|
if (!dashboardEnabled) {
|
|
665
4457
|
res.writeHead(404, {
|
|
666
4458
|
"Content-Type": "text/plain",
|
|
4459
|
+
...SECURITY_HEADERS,
|
|
667
4460
|
...CORS_HEADERS,
|
|
668
4461
|
});
|
|
669
4462
|
res.end("Dashboard is disabled");
|
|
@@ -675,8 +4468,14 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
675
4468
|
// Static assets: /orgx/live/assets/* → dashboard/dist/assets/*
|
|
676
4469
|
// Hashed filenames get long-lived cache
|
|
677
4470
|
if (subPath.startsWith("assets/")) {
|
|
678
|
-
const assetPath =
|
|
679
|
-
|
|
4471
|
+
const assetPath = resolveSafeDistPath(subPath);
|
|
4472
|
+
let isWithinAssetsDir = false;
|
|
4473
|
+
if (assetPath) {
|
|
4474
|
+
isWithinAssetsDir =
|
|
4475
|
+
assetPath === RESOLVED_DIST_ASSETS_DIR ||
|
|
4476
|
+
assetPath.startsWith(`${RESOLVED_DIST_ASSETS_DIR}${sep}`);
|
|
4477
|
+
}
|
|
4478
|
+
if (assetPath && isWithinAssetsDir && existsSync(assetPath)) {
|
|
680
4479
|
sendFile(res, assetPath, "public, max-age=31536000, immutable");
|
|
681
4480
|
}
|
|
682
4481
|
else {
|
|
@@ -685,9 +4484,9 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
685
4484
|
return true;
|
|
686
4485
|
}
|
|
687
4486
|
// Check for an exact file match (e.g. favicon, manifest)
|
|
688
|
-
if (subPath
|
|
689
|
-
const filePath =
|
|
690
|
-
if (existsSync(filePath)) {
|
|
4487
|
+
if (subPath) {
|
|
4488
|
+
const filePath = resolveSafeDistPath(subPath);
|
|
4489
|
+
if (filePath && existsSync(filePath)) {
|
|
691
4490
|
sendFile(res, filePath, "no-cache");
|
|
692
4491
|
return true;
|
|
693
4492
|
}
|
|
@@ -701,6 +4500,7 @@ export function createHttpHandler(config, client, getSnapshot) {
|
|
|
701
4500
|
// Redirect to dashboard
|
|
702
4501
|
res.writeHead(302, {
|
|
703
4502
|
Location: "/orgx/live",
|
|
4503
|
+
...SECURITY_HEADERS,
|
|
704
4504
|
...CORS_HEADERS,
|
|
705
4505
|
});
|
|
706
4506
|
res.end();
|