@harness-engineering/orchestrator 0.4.2 → 0.4.3
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/dist/index.d.mts +141 -5
- package/dist/index.d.ts +141 -5
- package/dist/index.js +1715 -144
- package/dist/index.mjs +1697 -123
- package/package.json +10 -3
package/dist/index.mjs
CHANGED
|
@@ -258,11 +258,18 @@ import * as path from "path";
|
|
|
258
258
|
var InteractionQueue = class {
|
|
259
259
|
dir;
|
|
260
260
|
pushListeners = [];
|
|
261
|
+
emitter;
|
|
261
262
|
/**
|
|
262
263
|
* @param dir - Directory path for storing interaction JSON files
|
|
264
|
+
* @param emitter - Optional event bus that receives `interaction.created`
|
|
265
|
+
* and `interaction.resolved` events. When omitted, the queue behaves as
|
|
266
|
+
* it did pre-Phase-2 (no emission). Phase 2 Task 8 wires the
|
|
267
|
+
* orchestrator (itself an EventEmitter) in as the bus so the SSE
|
|
268
|
+
* handler (`GET /api/v1/events`) can fan these out to clients.
|
|
263
269
|
*/
|
|
264
|
-
constructor(dir) {
|
|
270
|
+
constructor(dir, emitter) {
|
|
265
271
|
this.dir = dir;
|
|
272
|
+
this.emitter = emitter ?? null;
|
|
266
273
|
}
|
|
267
274
|
/**
|
|
268
275
|
* Register a listener that fires after each push.
|
|
@@ -290,6 +297,13 @@ var InteractionQueue = class {
|
|
|
290
297
|
for (const listener of this.pushListeners) {
|
|
291
298
|
listener(interaction);
|
|
292
299
|
}
|
|
300
|
+
this.emitter?.emit("interaction.created", {
|
|
301
|
+
id: interaction.id,
|
|
302
|
+
issueId: interaction.issueId,
|
|
303
|
+
type: interaction.type,
|
|
304
|
+
status: interaction.status,
|
|
305
|
+
createdAt: interaction.createdAt
|
|
306
|
+
});
|
|
293
307
|
}
|
|
294
308
|
/**
|
|
295
309
|
* List all interactions (regardless of status).
|
|
@@ -336,6 +350,13 @@ var InteractionQueue = class {
|
|
|
336
350
|
const interaction = JSON.parse(raw);
|
|
337
351
|
interaction.status = status;
|
|
338
352
|
await fs.writeFile(filePath, JSON.stringify(interaction, null, 2), "utf-8");
|
|
353
|
+
if (status === "resolved") {
|
|
354
|
+
this.emitter?.emit("interaction.resolved", {
|
|
355
|
+
id,
|
|
356
|
+
status: "resolved",
|
|
357
|
+
resolvedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
358
|
+
});
|
|
359
|
+
}
|
|
339
360
|
}
|
|
340
361
|
};
|
|
341
362
|
|
|
@@ -3907,6 +3928,13 @@ function extractUsage(usage) {
|
|
|
3907
3928
|
cacheReadTokens: usage.cache_read_input_tokens ?? 0
|
|
3908
3929
|
};
|
|
3909
3930
|
}
|
|
3931
|
+
function recordCacheUsage(recorder, rawUsage) {
|
|
3932
|
+
if (!recorder || !rawUsage) return;
|
|
3933
|
+
const cacheRead = rawUsage.cache_read_input_tokens ?? 0;
|
|
3934
|
+
const cacheCreation = rawUsage.cache_creation_input_tokens ?? 0;
|
|
3935
|
+
if (cacheRead === 0 && cacheCreation === 0) return;
|
|
3936
|
+
recorder.record("anthropic", cacheRead > 0, cacheCreation, cacheRead);
|
|
3937
|
+
}
|
|
3910
3938
|
function extractToolResultText(blockContent) {
|
|
3911
3939
|
if (typeof blockContent === "string") return blockContent;
|
|
3912
3940
|
if (!Array.isArray(blockContent)) return "";
|
|
@@ -3988,8 +4016,10 @@ function mapClaudeEvent(rawEvent, sessionId) {
|
|
|
3988
4016
|
var ClaudeBackend = class {
|
|
3989
4017
|
name = "claude";
|
|
3990
4018
|
command;
|
|
3991
|
-
|
|
3992
|
-
|
|
4019
|
+
cacheMetrics;
|
|
4020
|
+
constructor(command = "claude", options = {}) {
|
|
4021
|
+
this.command = options.command ?? command;
|
|
4022
|
+
if (options.cacheMetrics) this.cacheMetrics = options.cacheMetrics;
|
|
3993
4023
|
}
|
|
3994
4024
|
async startSession(params) {
|
|
3995
4025
|
const session = {
|
|
@@ -4055,6 +4085,9 @@ var ClaudeBackend = class {
|
|
|
4055
4085
|
totalTokens: 0
|
|
4056
4086
|
}
|
|
4057
4087
|
};
|
|
4088
|
+
recordCacheUsage(this.cacheMetrics, rawEvent.usage);
|
|
4089
|
+
} else if (rawEvent.type === "assistant" && rawEvent.message?.stop_reason !== null && rawEvent.message?.stop_reason !== void 0) {
|
|
4090
|
+
recordCacheUsage(this.cacheMetrics, rawEvent.message?.usage);
|
|
4058
4091
|
}
|
|
4059
4092
|
for (const mapped of mapClaudeEvent(rawEvent, session.sessionId)) {
|
|
4060
4093
|
yield mapped;
|
|
@@ -4923,12 +4956,14 @@ function makeGetModel(model) {
|
|
|
4923
4956
|
if (Array.isArray(model) && model.length > 0) return () => model[0] ?? null;
|
|
4924
4957
|
return () => null;
|
|
4925
4958
|
}
|
|
4926
|
-
function createBackend(def) {
|
|
4959
|
+
function createBackend(def, options = {}) {
|
|
4927
4960
|
switch (def.type) {
|
|
4928
4961
|
case "mock":
|
|
4929
4962
|
return new MockBackend();
|
|
4930
4963
|
case "claude":
|
|
4931
|
-
return new ClaudeBackend(def.command ?? "claude"
|
|
4964
|
+
return new ClaudeBackend(def.command ?? "claude", {
|
|
4965
|
+
...options.cacheMetrics ? { cacheMetrics: options.cacheMetrics } : {}
|
|
4966
|
+
});
|
|
4932
4967
|
case "anthropic":
|
|
4933
4968
|
return new AnthropicBackend({
|
|
4934
4969
|
model: def.model,
|
|
@@ -5366,11 +5401,12 @@ var OrchestratorBackendFactory = class {
|
|
|
5366
5401
|
const def = this.router.resolveDefinition(useCase);
|
|
5367
5402
|
const name = this.router.resolve(useCase);
|
|
5368
5403
|
let backend;
|
|
5404
|
+
const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
|
|
5369
5405
|
if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
|
|
5370
5406
|
const getModel = this.opts.getResolverModelFor(name);
|
|
5371
|
-
backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def);
|
|
5407
|
+
backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def, createOpts);
|
|
5372
5408
|
} else {
|
|
5373
|
-
backend = createBackend(def);
|
|
5409
|
+
backend = createBackend(def, createOpts);
|
|
5374
5410
|
}
|
|
5375
5411
|
if (this.opts.sandboxPolicy === "docker" && this.opts.container) {
|
|
5376
5412
|
backend = this.wrapInContainer(backend);
|
|
@@ -5766,13 +5802,75 @@ function handleInteractionsRoute(req, res, queue) {
|
|
|
5766
5802
|
return false;
|
|
5767
5803
|
}
|
|
5768
5804
|
|
|
5769
|
-
// src/server/routes/
|
|
5805
|
+
// src/server/routes/v1/interactions-resolve.ts
|
|
5770
5806
|
import { z as z4 } from "zod";
|
|
5807
|
+
var BodySchema = z4.object({ answer: z4.unknown().optional() });
|
|
5808
|
+
var RESOLVE_PATH_RE = /^\/api\/v1\/interactions\/([a-zA-Z0-9_-]+)\/resolve(?:\?.*)?$/;
|
|
5809
|
+
function sendJSON(res, status, body) {
|
|
5810
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
5811
|
+
res.end(JSON.stringify(body));
|
|
5812
|
+
}
|
|
5813
|
+
function handleV1InteractionsResolveRoute(req, res, queue) {
|
|
5814
|
+
if (req.method !== "POST") return false;
|
|
5815
|
+
const match = RESOLVE_PATH_RE.exec(req.url ?? "");
|
|
5816
|
+
if (!match || !match[1]) return false;
|
|
5817
|
+
if (!queue) {
|
|
5818
|
+
sendJSON(res, 503, { error: "Interaction queue not available" });
|
|
5819
|
+
return true;
|
|
5820
|
+
}
|
|
5821
|
+
const id = match[1];
|
|
5822
|
+
void (async () => {
|
|
5823
|
+
let raw;
|
|
5824
|
+
try {
|
|
5825
|
+
raw = await readBody(req);
|
|
5826
|
+
} catch {
|
|
5827
|
+
sendJSON(res, 413, { error: "Body too large" });
|
|
5828
|
+
return;
|
|
5829
|
+
}
|
|
5830
|
+
if (raw.length > 0) {
|
|
5831
|
+
try {
|
|
5832
|
+
const json = JSON.parse(raw);
|
|
5833
|
+
const parsed = BodySchema.safeParse(json);
|
|
5834
|
+
if (!parsed.success) {
|
|
5835
|
+
sendJSON(res, 400, { error: "Invalid body", issues: parsed.error.issues });
|
|
5836
|
+
return;
|
|
5837
|
+
}
|
|
5838
|
+
} catch {
|
|
5839
|
+
sendJSON(res, 400, { error: "Invalid JSON body" });
|
|
5840
|
+
return;
|
|
5841
|
+
}
|
|
5842
|
+
}
|
|
5843
|
+
try {
|
|
5844
|
+
const existing = (await queue.list()).find((i) => i.id === id);
|
|
5845
|
+
if (!existing) {
|
|
5846
|
+
sendJSON(res, 404, { error: `Interaction ${id} not found` });
|
|
5847
|
+
return;
|
|
5848
|
+
}
|
|
5849
|
+
if (existing.status === "resolved") {
|
|
5850
|
+
sendJSON(res, 409, { error: `Interaction ${id} already resolved` });
|
|
5851
|
+
return;
|
|
5852
|
+
}
|
|
5853
|
+
await queue.updateStatus(id, "resolved");
|
|
5854
|
+
sendJSON(res, 200, { resolved: true });
|
|
5855
|
+
} catch (err) {
|
|
5856
|
+
const msg = err instanceof Error ? err.message : "Failed to resolve";
|
|
5857
|
+
if (msg.includes("not found")) {
|
|
5858
|
+
sendJSON(res, 404, { error: msg });
|
|
5859
|
+
return;
|
|
5860
|
+
}
|
|
5861
|
+
sendJSON(res, 500, { error: "Internal error resolving interaction" });
|
|
5862
|
+
}
|
|
5863
|
+
})();
|
|
5864
|
+
return true;
|
|
5865
|
+
}
|
|
5866
|
+
|
|
5867
|
+
// src/server/routes/plans.ts
|
|
5868
|
+
import { z as z5 } from "zod";
|
|
5771
5869
|
import * as fs9 from "fs/promises";
|
|
5772
5870
|
import * as path9 from "path";
|
|
5773
|
-
var PlanWriteSchema =
|
|
5774
|
-
filename:
|
|
5775
|
-
content:
|
|
5871
|
+
var PlanWriteSchema = z5.object({
|
|
5872
|
+
filename: z5.string().min(1),
|
|
5873
|
+
content: z5.string().min(1)
|
|
5776
5874
|
});
|
|
5777
5875
|
function handlePlansRoute(req, res, plansDir) {
|
|
5778
5876
|
const { method, url } = req;
|
|
@@ -5816,7 +5914,7 @@ function handlePlansRoute(req, res, plansDir) {
|
|
|
5816
5914
|
import { spawn as spawn4 } from "child_process";
|
|
5817
5915
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
5818
5916
|
import * as readline2 from "readline";
|
|
5819
|
-
import { z as
|
|
5917
|
+
import { z as z6 } from "zod";
|
|
5820
5918
|
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5821
5919
|
var SAFE_ENV_PREFIXES = [
|
|
5822
5920
|
"PATH",
|
|
@@ -5851,10 +5949,10 @@ function buildChildEnv() {
|
|
|
5851
5949
|
}
|
|
5852
5950
|
return env;
|
|
5853
5951
|
}
|
|
5854
|
-
var ChatRequestSchema =
|
|
5855
|
-
prompt:
|
|
5856
|
-
system:
|
|
5857
|
-
sessionId:
|
|
5952
|
+
var ChatRequestSchema = z6.object({
|
|
5953
|
+
prompt: z6.string().min(1),
|
|
5954
|
+
system: z6.string().optional(),
|
|
5955
|
+
sessionId: z6.string().regex(UUID_RE).optional()
|
|
5858
5956
|
});
|
|
5859
5957
|
function handleChatProxyRoute(req, res, command = "claude") {
|
|
5860
5958
|
const { method, url } = req;
|
|
@@ -6064,11 +6162,11 @@ function extractChunks(event) {
|
|
|
6064
6162
|
|
|
6065
6163
|
// src/server/routes/analyze.ts
|
|
6066
6164
|
import { manualToRawWorkItem, scoreToConcernSignals } from "@harness-engineering/intelligence";
|
|
6067
|
-
import { z as
|
|
6068
|
-
var AnalyzeRequestSchema =
|
|
6069
|
-
title:
|
|
6070
|
-
description:
|
|
6071
|
-
labels:
|
|
6165
|
+
import { z as z7 } from "zod";
|
|
6166
|
+
var AnalyzeRequestSchema = z7.object({
|
|
6167
|
+
title: z7.string().min(1),
|
|
6168
|
+
description: z7.string().optional(),
|
|
6169
|
+
labels: z7.array(z7.string()).optional()
|
|
6072
6170
|
});
|
|
6073
6171
|
function emit2(res, event) {
|
|
6074
6172
|
res.write(`data: ${JSON.stringify(event)}
|
|
@@ -6185,21 +6283,21 @@ import {
|
|
|
6185
6283
|
ConflictError,
|
|
6186
6284
|
makeTrackerConflictBody
|
|
6187
6285
|
} from "@harness-engineering/core";
|
|
6188
|
-
import { z as
|
|
6189
|
-
var AppendRoadmapRequestSchema =
|
|
6190
|
-
title:
|
|
6191
|
-
summary:
|
|
6192
|
-
labels:
|
|
6193
|
-
enrichedSpec:
|
|
6194
|
-
intent:
|
|
6195
|
-
unknowns:
|
|
6196
|
-
ambiguities:
|
|
6197
|
-
riskSignals:
|
|
6198
|
-
affectedSystems:
|
|
6286
|
+
import { z as z8 } from "zod";
|
|
6287
|
+
var AppendRoadmapRequestSchema = z8.object({
|
|
6288
|
+
title: z8.string().min(1),
|
|
6289
|
+
summary: z8.string().optional(),
|
|
6290
|
+
labels: z8.array(z8.string()).optional(),
|
|
6291
|
+
enrichedSpec: z8.object({
|
|
6292
|
+
intent: z8.string(),
|
|
6293
|
+
unknowns: z8.array(z8.string()),
|
|
6294
|
+
ambiguities: z8.array(z8.string()),
|
|
6295
|
+
riskSignals: z8.array(z8.string()),
|
|
6296
|
+
affectedSystems: z8.array(z8.object({ name: z8.string() }))
|
|
6199
6297
|
}).optional(),
|
|
6200
|
-
cmlRecommendedRoute:
|
|
6298
|
+
cmlRecommendedRoute: z8.enum(["local", "human", "simulation-required"]).optional()
|
|
6201
6299
|
});
|
|
6202
|
-
function
|
|
6300
|
+
function sendJSON2(res, status, body) {
|
|
6203
6301
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6204
6302
|
res.end(JSON.stringify(body));
|
|
6205
6303
|
}
|
|
@@ -6208,7 +6306,7 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
|
|
|
6208
6306
|
void (async () => {
|
|
6209
6307
|
try {
|
|
6210
6308
|
if (!roadmapPath) {
|
|
6211
|
-
|
|
6309
|
+
sendJSON2(res, 503, { error: "Roadmap path not configured" });
|
|
6212
6310
|
return;
|
|
6213
6311
|
}
|
|
6214
6312
|
const projectRoot = path10.dirname(path10.dirname(roadmapPath));
|
|
@@ -6216,18 +6314,18 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
|
|
|
6216
6314
|
if (mode === "file-less") {
|
|
6217
6315
|
const trackerCfg = loadTrackerClientConfigFromProject(projectRoot);
|
|
6218
6316
|
if (!trackerCfg.ok) {
|
|
6219
|
-
|
|
6317
|
+
sendJSON2(res, 500, { error: trackerCfg.error.message });
|
|
6220
6318
|
return;
|
|
6221
6319
|
}
|
|
6222
6320
|
const clientR = createTrackerClient(trackerCfg.value);
|
|
6223
6321
|
if (!clientR.ok) {
|
|
6224
|
-
|
|
6322
|
+
sendJSON2(res, 500, { error: clientR.error.message });
|
|
6225
6323
|
return;
|
|
6226
6324
|
}
|
|
6227
6325
|
const body2 = await readBody(req);
|
|
6228
6326
|
const parseResult = AppendRoadmapRequestSchema.safeParse(JSON.parse(body2));
|
|
6229
6327
|
if (!parseResult.success) {
|
|
6230
|
-
|
|
6328
|
+
sendJSON2(res, 400, {
|
|
6231
6329
|
error: parseResult.error.issues[0]?.message ?? "Invalid request body"
|
|
6232
6330
|
});
|
|
6233
6331
|
return;
|
|
@@ -6240,13 +6338,13 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
|
|
|
6240
6338
|
const r = await clientR.value.create(newFeature);
|
|
6241
6339
|
if (!r.ok) {
|
|
6242
6340
|
if (r.error instanceof ConflictError) {
|
|
6243
|
-
|
|
6341
|
+
sendJSON2(res, 409, makeTrackerConflictBody(r.error));
|
|
6244
6342
|
return;
|
|
6245
6343
|
}
|
|
6246
|
-
|
|
6344
|
+
sendJSON2(res, 502, { error: r.error.message });
|
|
6247
6345
|
return;
|
|
6248
6346
|
}
|
|
6249
|
-
|
|
6347
|
+
sendJSON2(res, 201, {
|
|
6250
6348
|
ok: true,
|
|
6251
6349
|
featureName: r.value.name,
|
|
6252
6350
|
externalId: r.value.externalId
|
|
@@ -6256,18 +6354,18 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
|
|
|
6256
6354
|
const body = await readBody(req);
|
|
6257
6355
|
const result = AppendRoadmapRequestSchema.safeParse(JSON.parse(body));
|
|
6258
6356
|
if (!result.success) {
|
|
6259
|
-
|
|
6357
|
+
sendJSON2(res, 400, { error: result.error.issues[0]?.message ?? "Invalid request body" });
|
|
6260
6358
|
return;
|
|
6261
6359
|
}
|
|
6262
6360
|
const parsed = result.data;
|
|
6263
6361
|
if (parsed.title.includes("\n") || parsed.title.includes("###")) {
|
|
6264
|
-
|
|
6362
|
+
sendJSON2(res, 400, { error: "Title must not contain newlines or markdown headings" });
|
|
6265
6363
|
return;
|
|
6266
6364
|
}
|
|
6267
6365
|
const content = await fs10.readFile(roadmapPath, "utf-8");
|
|
6268
6366
|
const roadmapResult = parseRoadmap2(content);
|
|
6269
6367
|
if (!roadmapResult.ok) {
|
|
6270
|
-
|
|
6368
|
+
sendJSON2(res, 500, { error: "Failed to parse roadmap file" });
|
|
6271
6369
|
return;
|
|
6272
6370
|
}
|
|
6273
6371
|
const roadmap = roadmapResult.value;
|
|
@@ -6297,11 +6395,11 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
|
|
|
6297
6395
|
const serialized = serializeRoadmap2(roadmap);
|
|
6298
6396
|
await fs10.writeFile(tmpPath, serialized, "utf-8");
|
|
6299
6397
|
await fs10.rename(tmpPath, roadmapPath);
|
|
6300
|
-
|
|
6398
|
+
sendJSON2(res, 201, { ok: true, featureName: parsed.title });
|
|
6301
6399
|
} catch (err) {
|
|
6302
6400
|
const msg = err instanceof Error ? err.message : "Failed to append to roadmap";
|
|
6303
6401
|
if (!res.headersSent) {
|
|
6304
|
-
|
|
6402
|
+
sendJSON2(res, 500, { error: msg });
|
|
6305
6403
|
}
|
|
6306
6404
|
}
|
|
6307
6405
|
})();
|
|
@@ -6310,13 +6408,13 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
|
|
|
6310
6408
|
|
|
6311
6409
|
// src/server/routes/dispatch-actions.ts
|
|
6312
6410
|
import { createHash as createHash3 } from "crypto";
|
|
6313
|
-
import { z as
|
|
6314
|
-
var DispatchAdHocRequestSchema =
|
|
6315
|
-
title:
|
|
6316
|
-
description:
|
|
6317
|
-
labels:
|
|
6411
|
+
import { z as z9 } from "zod";
|
|
6412
|
+
var DispatchAdHocRequestSchema = z9.object({
|
|
6413
|
+
title: z9.string().min(1),
|
|
6414
|
+
description: z9.string().optional(),
|
|
6415
|
+
labels: z9.array(z9.string()).optional()
|
|
6318
6416
|
});
|
|
6319
|
-
function
|
|
6417
|
+
function sendJSON3(res, status, body) {
|
|
6320
6418
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6321
6419
|
res.end(JSON.stringify(body));
|
|
6322
6420
|
}
|
|
@@ -6330,13 +6428,13 @@ function handleDispatchActionsRoute(req, res, dispatchFn) {
|
|
|
6330
6428
|
void (async () => {
|
|
6331
6429
|
try {
|
|
6332
6430
|
if (!dispatchFn) {
|
|
6333
|
-
|
|
6431
|
+
sendJSON3(res, 503, { error: "Dispatch not available" });
|
|
6334
6432
|
return;
|
|
6335
6433
|
}
|
|
6336
6434
|
const body = await readBody(req);
|
|
6337
6435
|
const result = DispatchAdHocRequestSchema.safeParse(JSON.parse(body));
|
|
6338
6436
|
if (!result.success) {
|
|
6339
|
-
|
|
6437
|
+
sendJSON3(res, 400, { error: result.error.issues[0]?.message ?? "Invalid request body" });
|
|
6340
6438
|
return;
|
|
6341
6439
|
}
|
|
6342
6440
|
const parsed = result.data;
|
|
@@ -6359,11 +6457,11 @@ function handleDispatchActionsRoute(req, res, dispatchFn) {
|
|
|
6359
6457
|
externalId: null
|
|
6360
6458
|
};
|
|
6361
6459
|
await dispatchFn(issue);
|
|
6362
|
-
|
|
6460
|
+
sendJSON3(res, 200, { ok: true, issueId: id });
|
|
6363
6461
|
} catch (err) {
|
|
6364
6462
|
const msg = err instanceof Error ? err.message : "Dispatch failed";
|
|
6365
6463
|
if (!res.headersSent) {
|
|
6366
|
-
|
|
6464
|
+
sendJSON3(res, 500, { error: msg });
|
|
6367
6465
|
}
|
|
6368
6466
|
}
|
|
6369
6467
|
})();
|
|
@@ -6411,7 +6509,7 @@ function handleAnalysesRoute(req, res, archive) {
|
|
|
6411
6509
|
}
|
|
6412
6510
|
|
|
6413
6511
|
// src/server/routes/maintenance.ts
|
|
6414
|
-
import { z as
|
|
6512
|
+
import { z as z10 } from "zod";
|
|
6415
6513
|
function toMaintenanceHistoryEntry(r) {
|
|
6416
6514
|
const durationMs = r.startedAt && r.completedAt ? Date.parse(r.completedAt) - Date.parse(r.startedAt) : 0;
|
|
6417
6515
|
const status = r.status === "failure" ? "failed" : r.status;
|
|
@@ -6426,27 +6524,27 @@ function toMaintenanceHistoryEntry(r) {
|
|
|
6426
6524
|
if (r.error !== void 0) entry.error = r.error;
|
|
6427
6525
|
return entry;
|
|
6428
6526
|
}
|
|
6429
|
-
var TriggerRequestSchema =
|
|
6430
|
-
taskId:
|
|
6527
|
+
var TriggerRequestSchema = z10.object({
|
|
6528
|
+
taskId: z10.string().min(1)
|
|
6431
6529
|
});
|
|
6432
|
-
function
|
|
6530
|
+
function sendJSON4(res, status, body) {
|
|
6433
6531
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6434
6532
|
res.end(JSON.stringify(body));
|
|
6435
6533
|
}
|
|
6436
6534
|
function handleGetSchedule(res, deps) {
|
|
6437
6535
|
const status = deps.scheduler.getStatus();
|
|
6438
|
-
|
|
6536
|
+
sendJSON4(res, 200, status.schedule);
|
|
6439
6537
|
}
|
|
6440
6538
|
function handleGetStatus(res, deps) {
|
|
6441
6539
|
const status = deps.scheduler.getStatus();
|
|
6442
|
-
|
|
6540
|
+
sendJSON4(res, 200, status);
|
|
6443
6541
|
}
|
|
6444
6542
|
function handleGetHistory(res, deps, queryString) {
|
|
6445
6543
|
const params = new URLSearchParams(queryString);
|
|
6446
6544
|
const limit = Math.min(100, Math.max(1, parseInt(params.get("limit") ?? "20", 10) || 20));
|
|
6447
6545
|
const offset = Math.max(0, parseInt(params.get("offset") ?? "0", 10) || 0);
|
|
6448
6546
|
const history = deps.reporter.getHistory(limit, offset).map(toMaintenanceHistoryEntry);
|
|
6449
|
-
|
|
6547
|
+
sendJSON4(res, 200, history);
|
|
6450
6548
|
}
|
|
6451
6549
|
function handlePostTrigger(req, res, deps) {
|
|
6452
6550
|
void (async () => {
|
|
@@ -6456,20 +6554,20 @@ function handlePostTrigger(req, res, deps) {
|
|
|
6456
6554
|
try {
|
|
6457
6555
|
json = JSON.parse(body);
|
|
6458
6556
|
} catch {
|
|
6459
|
-
|
|
6557
|
+
sendJSON4(res, 400, { error: "Invalid JSON body" });
|
|
6460
6558
|
return;
|
|
6461
6559
|
}
|
|
6462
6560
|
const result = TriggerRequestSchema.safeParse(json);
|
|
6463
6561
|
if (!result.success) {
|
|
6464
|
-
|
|
6562
|
+
sendJSON4(res, 400, { error: "Missing taskId string" });
|
|
6465
6563
|
return;
|
|
6466
6564
|
}
|
|
6467
6565
|
await deps.triggerFn(result.data.taskId);
|
|
6468
|
-
|
|
6566
|
+
sendJSON4(res, 200, { ok: true, taskId: result.data.taskId });
|
|
6469
6567
|
} catch (err) {
|
|
6470
6568
|
const msg = err instanceof Error ? err.message : "Trigger failed";
|
|
6471
6569
|
if (!res.headersSent) {
|
|
6472
|
-
|
|
6570
|
+
sendJSON4(res, 500, { error: msg });
|
|
6473
6571
|
}
|
|
6474
6572
|
}
|
|
6475
6573
|
})();
|
|
@@ -6478,7 +6576,7 @@ function handleMaintenanceRoute(req, res, deps) {
|
|
|
6478
6576
|
const { method, url } = req;
|
|
6479
6577
|
if (!url?.startsWith("/api/maintenance/")) return false;
|
|
6480
6578
|
if (!deps) {
|
|
6481
|
-
|
|
6579
|
+
sendJSON4(res, 503, { error: "Maintenance not available" });
|
|
6482
6580
|
return true;
|
|
6483
6581
|
}
|
|
6484
6582
|
const [pathname, queryString] = url.split("?");
|
|
@@ -6499,16 +6597,296 @@ function handleMaintenanceRoute(req, res, deps) {
|
|
|
6499
6597
|
handlePostTrigger(req, res, deps);
|
|
6500
6598
|
return true;
|
|
6501
6599
|
}
|
|
6502
|
-
|
|
6600
|
+
sendJSON4(res, 404, { error: "Not found" });
|
|
6503
6601
|
return true;
|
|
6504
6602
|
}
|
|
6505
6603
|
|
|
6604
|
+
// src/server/routes/v1/jobs-maintenance.ts
|
|
6605
|
+
import { randomBytes } from "crypto";
|
|
6606
|
+
import { z as z11 } from "zod";
|
|
6607
|
+
var BodySchema2 = z11.object({
|
|
6608
|
+
taskId: z11.string().min(1).max(200),
|
|
6609
|
+
params: z11.record(z11.unknown()).optional()
|
|
6610
|
+
});
|
|
6611
|
+
function sendJSON5(res, status, body) {
|
|
6612
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6613
|
+
res.end(JSON.stringify(body));
|
|
6614
|
+
}
|
|
6615
|
+
function handleV1JobsMaintenanceRoute(req, res, deps) {
|
|
6616
|
+
if (req.url !== "/api/v1/jobs/maintenance" || req.method !== "POST") return false;
|
|
6617
|
+
if (!deps) {
|
|
6618
|
+
sendJSON5(res, 503, { error: "Maintenance not available" });
|
|
6619
|
+
return true;
|
|
6620
|
+
}
|
|
6621
|
+
void (async () => {
|
|
6622
|
+
let raw;
|
|
6623
|
+
try {
|
|
6624
|
+
raw = await readBody(req);
|
|
6625
|
+
} catch (err) {
|
|
6626
|
+
sendJSON5(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
|
|
6627
|
+
return;
|
|
6628
|
+
}
|
|
6629
|
+
let json;
|
|
6630
|
+
try {
|
|
6631
|
+
json = JSON.parse(raw);
|
|
6632
|
+
} catch {
|
|
6633
|
+
sendJSON5(res, 400, { error: "Invalid JSON body" });
|
|
6634
|
+
return;
|
|
6635
|
+
}
|
|
6636
|
+
const parsed = BodySchema2.safeParse(json);
|
|
6637
|
+
if (!parsed.success) {
|
|
6638
|
+
sendJSON5(res, 400, { error: "Invalid body", issues: parsed.error.issues });
|
|
6639
|
+
return;
|
|
6640
|
+
}
|
|
6641
|
+
const runId = `run_${randomBytes(8).toString("hex")}`;
|
|
6642
|
+
try {
|
|
6643
|
+
await deps.triggerFn(parsed.data.taskId);
|
|
6644
|
+
sendJSON5(res, 200, { ok: true, taskId: parsed.data.taskId, runId });
|
|
6645
|
+
} catch (err) {
|
|
6646
|
+
const msg = err instanceof Error ? err.message : "Trigger failed";
|
|
6647
|
+
const lower = msg.toLowerCase();
|
|
6648
|
+
if (lower.includes("unknown task") || lower.includes("not found")) {
|
|
6649
|
+
sendJSON5(res, 404, { error: msg });
|
|
6650
|
+
return;
|
|
6651
|
+
}
|
|
6652
|
+
if (lower.includes("already running")) {
|
|
6653
|
+
sendJSON5(res, 409, { error: msg });
|
|
6654
|
+
return;
|
|
6655
|
+
}
|
|
6656
|
+
sendJSON5(res, 500, { error: "Internal error triggering maintenance task" });
|
|
6657
|
+
}
|
|
6658
|
+
})();
|
|
6659
|
+
return true;
|
|
6660
|
+
}
|
|
6661
|
+
|
|
6662
|
+
// src/server/routes/v1/events-sse.ts
|
|
6663
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
6664
|
+
var SSE_TOPICS = [
|
|
6665
|
+
"state_change",
|
|
6666
|
+
"agent_event",
|
|
6667
|
+
"interaction.created",
|
|
6668
|
+
"interaction.resolved",
|
|
6669
|
+
"maintenance:started",
|
|
6670
|
+
"maintenance:completed",
|
|
6671
|
+
"maintenance:error",
|
|
6672
|
+
"maintenance:baseref_fallback",
|
|
6673
|
+
"local-model:status",
|
|
6674
|
+
// ── Phase 3 ──
|
|
6675
|
+
"webhook.subscription.created",
|
|
6676
|
+
"webhook.subscription.deleted"
|
|
6677
|
+
];
|
|
6678
|
+
var HEARTBEAT_MS = 15e3;
|
|
6679
|
+
function newEventId() {
|
|
6680
|
+
return `evt_${randomBytes2(8).toString("hex")}`;
|
|
6681
|
+
}
|
|
6682
|
+
function handleV1EventsSseRoute(req, res, bus) {
|
|
6683
|
+
if (req.method !== "GET" || req.url !== "/api/v1/events") return false;
|
|
6684
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
6685
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
6686
|
+
res.setHeader("Connection", "keep-alive");
|
|
6687
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
6688
|
+
res.writeHead(200);
|
|
6689
|
+
res.write(`: harness gateway SSE \u2014 connected at ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
6690
|
+
|
|
6691
|
+
`);
|
|
6692
|
+
const listeners = [];
|
|
6693
|
+
for (const topic of SSE_TOPICS) {
|
|
6694
|
+
const fn = (data) => {
|
|
6695
|
+
try {
|
|
6696
|
+
const frame = `event: ${topic}
|
|
6697
|
+
data: ${JSON.stringify(data)}
|
|
6698
|
+
id: ${newEventId()}
|
|
6699
|
+
|
|
6700
|
+
`;
|
|
6701
|
+
res.write(frame);
|
|
6702
|
+
} catch {
|
|
6703
|
+
}
|
|
6704
|
+
};
|
|
6705
|
+
bus.on(topic, fn);
|
|
6706
|
+
listeners.push({ topic, fn });
|
|
6707
|
+
}
|
|
6708
|
+
const heartbeat = setInterval(() => {
|
|
6709
|
+
try {
|
|
6710
|
+
res.write(": heartbeat\n\n");
|
|
6711
|
+
} catch {
|
|
6712
|
+
}
|
|
6713
|
+
}, HEARTBEAT_MS);
|
|
6714
|
+
heartbeat.unref();
|
|
6715
|
+
const cleanup = () => {
|
|
6716
|
+
clearInterval(heartbeat);
|
|
6717
|
+
for (const { topic, fn } of listeners) bus.removeListener(topic, fn);
|
|
6718
|
+
};
|
|
6719
|
+
res.on("close", cleanup);
|
|
6720
|
+
res.on("finish", cleanup);
|
|
6721
|
+
return true;
|
|
6722
|
+
}
|
|
6723
|
+
|
|
6724
|
+
// src/server/routes/v1/webhooks.ts
|
|
6725
|
+
import { z as z12 } from "zod";
|
|
6726
|
+
|
|
6727
|
+
// src/server/utils/url-guard.ts
|
|
6728
|
+
var PRIVATE_HOSTNAME_RE = /^(localhost|.*\.local)$/i;
|
|
6729
|
+
var PRIVATE_IPV4_RE = /^(127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0)$/;
|
|
6730
|
+
var LOOPBACK_IPV6_RE = /^(::1|::ffff:127\.\d+\.\d+\.\d+|::ffff:0:127\.\d+\.\d+\.\d+)$/i;
|
|
6731
|
+
function isPrivateHost(hostname) {
|
|
6732
|
+
return PRIVATE_HOSTNAME_RE.test(hostname) || PRIVATE_IPV4_RE.test(hostname) || LOOPBACK_IPV6_RE.test(hostname);
|
|
6733
|
+
}
|
|
6734
|
+
|
|
6735
|
+
// src/server/routes/v1/webhooks.ts
|
|
6736
|
+
import { WebhookSubscriptionPublicSchema } from "@harness-engineering/types";
|
|
6737
|
+
function isAdminAuth(authContext) {
|
|
6738
|
+
if (!authContext) return false;
|
|
6739
|
+
if (authContext.scopes.includes("admin")) return true;
|
|
6740
|
+
if (authContext.id.startsWith("tok_legacy_env")) return true;
|
|
6741
|
+
return false;
|
|
6742
|
+
}
|
|
6743
|
+
function getAuthContext(req) {
|
|
6744
|
+
return req._authToken;
|
|
6745
|
+
}
|
|
6746
|
+
var CreateBody = z12.object({
|
|
6747
|
+
url: z12.string().url(),
|
|
6748
|
+
events: z12.array(z12.string().min(1)).min(1)
|
|
6749
|
+
});
|
|
6750
|
+
var QUEUE_STATS_PATH_RE = /^\/api\/v1\/webhooks\/queue\/stats(?:\?.*)?$/;
|
|
6751
|
+
var DELETE_PATH_RE = /^\/api\/v1\/webhooks\/([a-zA-Z0-9_-]+)(?:\?.*)?$/;
|
|
6752
|
+
var LIST_OR_CREATE_PATH_RE = /^\/api\/v1\/webhooks(?:\?.*)?$/;
|
|
6753
|
+
function sendJSON6(res, status, body) {
|
|
6754
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6755
|
+
res.end(JSON.stringify(body));
|
|
6756
|
+
}
|
|
6757
|
+
var unauthDevWarnedThisProcess = false;
|
|
6758
|
+
function maybeWarnUnauthDev(tokenId, url) {
|
|
6759
|
+
if (unauthDevWarnedThisProcess) return;
|
|
6760
|
+
const isUnauthDev = tokenId === "tok_legacy_env" || process.env["HARNESS_UNAUTH_DEV_ACTIVE"] === "1";
|
|
6761
|
+
if (!isUnauthDev) return;
|
|
6762
|
+
unauthDevWarnedThisProcess = true;
|
|
6763
|
+
console.warn(
|
|
6764
|
+
`[webhook] subscription created under unauth-dev mode (tokenId=${tokenId}). Webhook target URL: ${url}. Set HARNESS_API_TOKEN or configure tokens.json to silence this warning.`
|
|
6765
|
+
);
|
|
6766
|
+
}
|
|
6767
|
+
function handleV1WebhooksRoute(req, res, deps) {
|
|
6768
|
+
const url = req.url ?? "";
|
|
6769
|
+
const method = req.method ?? "GET";
|
|
6770
|
+
if (method === "GET" && QUEUE_STATS_PATH_RE.test(url)) {
|
|
6771
|
+
if (!deps.queue) {
|
|
6772
|
+
sendJSON6(res, 503, { error: "Queue not available" });
|
|
6773
|
+
return true;
|
|
6774
|
+
}
|
|
6775
|
+
sendJSON6(res, 200, deps.queue.stats());
|
|
6776
|
+
return true;
|
|
6777
|
+
}
|
|
6778
|
+
if (method === "GET" && LIST_OR_CREATE_PATH_RE.test(url)) {
|
|
6779
|
+
void (async () => {
|
|
6780
|
+
const subs = await deps.store.list();
|
|
6781
|
+
const authContext = getAuthContext(req);
|
|
6782
|
+
const visible = isAdminAuth(authContext) ? subs : subs.filter((s) => s.tokenId === authContext?.id);
|
|
6783
|
+
const publicView = visible.map((s) => WebhookSubscriptionPublicSchema.parse(s));
|
|
6784
|
+
sendJSON6(res, 200, publicView);
|
|
6785
|
+
})();
|
|
6786
|
+
return true;
|
|
6787
|
+
}
|
|
6788
|
+
if (method === "POST" && LIST_OR_CREATE_PATH_RE.test(url)) {
|
|
6789
|
+
void (async () => {
|
|
6790
|
+
let raw;
|
|
6791
|
+
try {
|
|
6792
|
+
raw = await readBody(req);
|
|
6793
|
+
} catch (err) {
|
|
6794
|
+
sendJSON6(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
|
|
6795
|
+
return;
|
|
6796
|
+
}
|
|
6797
|
+
let json;
|
|
6798
|
+
try {
|
|
6799
|
+
json = JSON.parse(raw);
|
|
6800
|
+
} catch {
|
|
6801
|
+
sendJSON6(res, 400, { error: "Invalid JSON body" });
|
|
6802
|
+
return;
|
|
6803
|
+
}
|
|
6804
|
+
const parsed = CreateBody.safeParse(json);
|
|
6805
|
+
if (!parsed.success) {
|
|
6806
|
+
sendJSON6(res, 400, { error: "Invalid body", issues: parsed.error.issues });
|
|
6807
|
+
return;
|
|
6808
|
+
}
|
|
6809
|
+
if (!parsed.data.url.startsWith("https://")) {
|
|
6810
|
+
sendJSON6(res, 422, { error: "URL must use https" });
|
|
6811
|
+
return;
|
|
6812
|
+
}
|
|
6813
|
+
const targetHostname = new URL(parsed.data.url).hostname;
|
|
6814
|
+
if (isPrivateHost(targetHostname)) {
|
|
6815
|
+
sendJSON6(res, 422, { error: "URL must not target private or loopback addresses" });
|
|
6816
|
+
return;
|
|
6817
|
+
}
|
|
6818
|
+
const tokenId = getAuthContext(req)?.id ?? "unknown";
|
|
6819
|
+
const sub = await deps.store.create({
|
|
6820
|
+
tokenId,
|
|
6821
|
+
url: parsed.data.url,
|
|
6822
|
+
events: parsed.data.events
|
|
6823
|
+
});
|
|
6824
|
+
maybeWarnUnauthDev(tokenId, parsed.data.url);
|
|
6825
|
+
deps.bus.emit("webhook.subscription.created", {
|
|
6826
|
+
id: sub.id,
|
|
6827
|
+
tokenId: sub.tokenId,
|
|
6828
|
+
url: sub.url,
|
|
6829
|
+
events: sub.events,
|
|
6830
|
+
createdAt: sub.createdAt
|
|
6831
|
+
});
|
|
6832
|
+
sendJSON6(res, 200, sub);
|
|
6833
|
+
})();
|
|
6834
|
+
return true;
|
|
6835
|
+
}
|
|
6836
|
+
const m = method === "DELETE" ? DELETE_PATH_RE.exec(url) : null;
|
|
6837
|
+
if (m) {
|
|
6838
|
+
const id = m[1] ?? "";
|
|
6839
|
+
void (async () => {
|
|
6840
|
+
const authContext = getAuthContext(req);
|
|
6841
|
+
const subs = await deps.store.list();
|
|
6842
|
+
const sub = subs.find((s) => s.id === id);
|
|
6843
|
+
if (!sub) {
|
|
6844
|
+
sendJSON6(res, 404, { error: "Subscription not found" });
|
|
6845
|
+
return;
|
|
6846
|
+
}
|
|
6847
|
+
if (!isAdminAuth(authContext) && sub.tokenId !== authContext?.id) {
|
|
6848
|
+
sendJSON6(res, 403, { error: "forbidden" });
|
|
6849
|
+
return;
|
|
6850
|
+
}
|
|
6851
|
+
const ok = await deps.store.delete(id);
|
|
6852
|
+
if (!ok) {
|
|
6853
|
+
sendJSON6(res, 404, { error: "Subscription not found" });
|
|
6854
|
+
return;
|
|
6855
|
+
}
|
|
6856
|
+
deps.bus.emit("webhook.subscription.deleted", { id });
|
|
6857
|
+
sendJSON6(res, 200, { deleted: true });
|
|
6858
|
+
})();
|
|
6859
|
+
return true;
|
|
6860
|
+
}
|
|
6861
|
+
return false;
|
|
6862
|
+
}
|
|
6863
|
+
|
|
6864
|
+
// src/server/routes/v1/telemetry.ts
|
|
6865
|
+
var CACHE_STATS_PATH_RE = /^\/api\/v1\/telemetry\/cache\/stats(?:\?.*)?$/;
|
|
6866
|
+
function sendJSON7(res, status, body) {
|
|
6867
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6868
|
+
res.end(JSON.stringify(body));
|
|
6869
|
+
}
|
|
6870
|
+
function handleV1TelemetryRoute(req, res, deps) {
|
|
6871
|
+
const url = req.url ?? "";
|
|
6872
|
+
const method = req.method ?? "GET";
|
|
6873
|
+
if (method === "GET" && CACHE_STATS_PATH_RE.test(url)) {
|
|
6874
|
+
if (!deps.cacheMetrics) {
|
|
6875
|
+
sendJSON7(res, 503, { error: "Cache metrics recorder not available" });
|
|
6876
|
+
return true;
|
|
6877
|
+
}
|
|
6878
|
+
sendJSON7(res, 200, deps.cacheMetrics.getStats());
|
|
6879
|
+
return true;
|
|
6880
|
+
}
|
|
6881
|
+
return false;
|
|
6882
|
+
}
|
|
6883
|
+
|
|
6506
6884
|
// src/server/routes/sessions.ts
|
|
6507
6885
|
import * as fs11 from "fs/promises";
|
|
6508
6886
|
import * as path11 from "path";
|
|
6509
|
-
import { z as
|
|
6510
|
-
var SessionCreateSchema =
|
|
6511
|
-
sessionId:
|
|
6887
|
+
import { z as z13 } from "zod";
|
|
6888
|
+
var SessionCreateSchema = z13.object({
|
|
6889
|
+
sessionId: z13.string().min(1)
|
|
6512
6890
|
}).passthrough();
|
|
6513
6891
|
var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6514
6892
|
function isSafeId(id) {
|
|
@@ -6595,7 +6973,7 @@ async function handleUpdate(req, res, url, sessionsDir) {
|
|
|
6595
6973
|
return;
|
|
6596
6974
|
}
|
|
6597
6975
|
const body = await readBody(req);
|
|
6598
|
-
const updates =
|
|
6976
|
+
const updates = z13.record(z13.unknown()).parse(JSON.parse(body));
|
|
6599
6977
|
const sessionFilePath = path11.join(sessionsDir, id, "session.json");
|
|
6600
6978
|
const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
|
|
6601
6979
|
await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
|
|
@@ -6714,8 +7092,116 @@ function handleStreamsRoute(req, res, recorder) {
|
|
|
6714
7092
|
return true;
|
|
6715
7093
|
}
|
|
6716
7094
|
|
|
7095
|
+
// src/server/routes/auth.ts
|
|
7096
|
+
import { z as z14 } from "zod";
|
|
7097
|
+
import {
|
|
7098
|
+
TokenScopeSchema,
|
|
7099
|
+
BridgeKindSchema,
|
|
7100
|
+
AuthTokenPublicSchema
|
|
7101
|
+
} from "@harness-engineering/types";
|
|
7102
|
+
var CreateBodySchema = z14.object({
|
|
7103
|
+
name: z14.string().min(1).max(100),
|
|
7104
|
+
scopes: z14.array(TokenScopeSchema).min(1),
|
|
7105
|
+
bridgeKind: BridgeKindSchema.optional(),
|
|
7106
|
+
tenantId: z14.string().optional(),
|
|
7107
|
+
expiresAt: z14.string().datetime().optional()
|
|
7108
|
+
});
|
|
7109
|
+
function sendJSON8(res, status, body) {
|
|
7110
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
7111
|
+
res.end(JSON.stringify(body));
|
|
7112
|
+
}
|
|
7113
|
+
async function handlePost(req, res, store) {
|
|
7114
|
+
let raw;
|
|
7115
|
+
try {
|
|
7116
|
+
raw = await readBody(req);
|
|
7117
|
+
} catch (err) {
|
|
7118
|
+
const msg = err instanceof Error ? err.message : "Failed to read body";
|
|
7119
|
+
sendJSON8(res, 413, { error: msg });
|
|
7120
|
+
return;
|
|
7121
|
+
}
|
|
7122
|
+
let json;
|
|
7123
|
+
try {
|
|
7124
|
+
json = JSON.parse(raw);
|
|
7125
|
+
} catch {
|
|
7126
|
+
sendJSON8(res, 400, { error: "Invalid JSON body" });
|
|
7127
|
+
return;
|
|
7128
|
+
}
|
|
7129
|
+
const parsed = CreateBodySchema.safeParse(json);
|
|
7130
|
+
if (!parsed.success) {
|
|
7131
|
+
sendJSON8(res, 422, { error: "Invalid body", issues: parsed.error.issues });
|
|
7132
|
+
return;
|
|
7133
|
+
}
|
|
7134
|
+
try {
|
|
7135
|
+
const input = {
|
|
7136
|
+
name: parsed.data.name,
|
|
7137
|
+
scopes: parsed.data.scopes
|
|
7138
|
+
};
|
|
7139
|
+
if (parsed.data.bridgeKind !== void 0) input.bridgeKind = parsed.data.bridgeKind;
|
|
7140
|
+
if (parsed.data.tenantId !== void 0) input.tenantId = parsed.data.tenantId;
|
|
7141
|
+
if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
|
|
7142
|
+
const result = await store.create(input);
|
|
7143
|
+
const publicRecord = AuthTokenPublicSchema.parse(result.record);
|
|
7144
|
+
sendJSON8(res, 200, {
|
|
7145
|
+
...publicRecord,
|
|
7146
|
+
token: result.token
|
|
7147
|
+
});
|
|
7148
|
+
} catch (err) {
|
|
7149
|
+
const msg = err instanceof Error ? err.message : "Failed to create token";
|
|
7150
|
+
if (msg.includes("already exists")) {
|
|
7151
|
+
sendJSON8(res, 409, { error: msg });
|
|
7152
|
+
return;
|
|
7153
|
+
}
|
|
7154
|
+
sendJSON8(res, 500, { error: "Internal error creating token" });
|
|
7155
|
+
}
|
|
7156
|
+
}
|
|
7157
|
+
async function handleList2(res, store) {
|
|
7158
|
+
try {
|
|
7159
|
+
const list = await store.list();
|
|
7160
|
+
sendJSON8(res, 200, list);
|
|
7161
|
+
} catch {
|
|
7162
|
+
sendJSON8(res, 500, { error: "Internal error listing tokens" });
|
|
7163
|
+
}
|
|
7164
|
+
}
|
|
7165
|
+
async function handleDelete2(res, store, id) {
|
|
7166
|
+
try {
|
|
7167
|
+
const ok = await store.revoke(id);
|
|
7168
|
+
if (!ok) {
|
|
7169
|
+
sendJSON8(res, 404, { error: "Token not found" });
|
|
7170
|
+
return;
|
|
7171
|
+
}
|
|
7172
|
+
sendJSON8(res, 200, { deleted: true });
|
|
7173
|
+
} catch {
|
|
7174
|
+
sendJSON8(res, 500, { error: "Internal error revoking token" });
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
|
|
7178
|
+
function handleAuthRoute(req, res, store) {
|
|
7179
|
+
const { method, url } = req;
|
|
7180
|
+
if (!url) return false;
|
|
7181
|
+
if (!url.startsWith("/api/v1/auth/")) return false;
|
|
7182
|
+
const [pathname] = url.split("?");
|
|
7183
|
+
if (method === "POST" && pathname === "/api/v1/auth/token") {
|
|
7184
|
+
void handlePost(req, res, store);
|
|
7185
|
+
return true;
|
|
7186
|
+
}
|
|
7187
|
+
if (method === "GET" && pathname === "/api/v1/auth/tokens") {
|
|
7188
|
+
void handleList2(res, store);
|
|
7189
|
+
return true;
|
|
7190
|
+
}
|
|
7191
|
+
if (method === "DELETE") {
|
|
7192
|
+
const match = (pathname ?? "").match(DELETE_PATH_RE2);
|
|
7193
|
+
if (match && match[1]) {
|
|
7194
|
+
const id = decodeURIComponent(match[1]);
|
|
7195
|
+
void handleDelete2(res, store, id);
|
|
7196
|
+
return true;
|
|
7197
|
+
}
|
|
7198
|
+
}
|
|
7199
|
+
sendJSON8(res, 405, { error: "Method not allowed" });
|
|
7200
|
+
return true;
|
|
7201
|
+
}
|
|
7202
|
+
|
|
6717
7203
|
// src/server/routes/local-model.ts
|
|
6718
|
-
function
|
|
7204
|
+
function sendJSON9(res, status, body) {
|
|
6719
7205
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
6720
7206
|
res.end(JSON.stringify(body));
|
|
6721
7207
|
}
|
|
@@ -6723,30 +7209,30 @@ function handleLocalModelRoute(req, res, getStatus) {
|
|
|
6723
7209
|
const { method, url } = req;
|
|
6724
7210
|
if (url !== "/api/v1/local-model/status") return false;
|
|
6725
7211
|
if (method !== "GET") {
|
|
6726
|
-
|
|
7212
|
+
sendJSON9(res, 405, { error: "Method not allowed" });
|
|
6727
7213
|
return true;
|
|
6728
7214
|
}
|
|
6729
7215
|
if (!getStatus) {
|
|
6730
|
-
|
|
7216
|
+
sendJSON9(res, 503, { error: "Local backend not configured" });
|
|
6731
7217
|
return true;
|
|
6732
7218
|
}
|
|
6733
7219
|
const status = getStatus();
|
|
6734
7220
|
if (!status) {
|
|
6735
|
-
|
|
7221
|
+
sendJSON9(res, 503, { error: "Local backend not configured" });
|
|
6736
7222
|
return true;
|
|
6737
7223
|
}
|
|
6738
|
-
|
|
7224
|
+
sendJSON9(res, 200, status);
|
|
6739
7225
|
return true;
|
|
6740
7226
|
}
|
|
6741
7227
|
function handleLocalModelsRoute(req, res, getStatuses) {
|
|
6742
7228
|
const { method, url } = req;
|
|
6743
7229
|
if (url !== "/api/v1/local-models/status") return false;
|
|
6744
7230
|
if (method !== "GET") {
|
|
6745
|
-
|
|
7231
|
+
sendJSON9(res, 405, { error: "Method not allowed" });
|
|
6746
7232
|
return true;
|
|
6747
7233
|
}
|
|
6748
7234
|
const statuses = getStatuses ? getStatuses() : [];
|
|
6749
|
-
|
|
7235
|
+
sendJSON9(res, 200, statuses);
|
|
6750
7236
|
return true;
|
|
6751
7237
|
}
|
|
6752
7238
|
|
|
@@ -6853,10 +7339,271 @@ var PlanWatcher = class {
|
|
|
6853
7339
|
}
|
|
6854
7340
|
};
|
|
6855
7341
|
|
|
7342
|
+
// src/auth/tokens.ts
|
|
7343
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
|
|
7344
|
+
import { readFile as readFile8, writeFile as writeFile8, mkdir as mkdir7, rename as rename2 } from "fs/promises";
|
|
7345
|
+
import { dirname as dirname4 } from "path";
|
|
7346
|
+
import bcrypt from "bcryptjs";
|
|
7347
|
+
import {
|
|
7348
|
+
AuthTokenSchema,
|
|
7349
|
+
AuthTokenPublicSchema as AuthTokenPublicSchema2
|
|
7350
|
+
} from "@harness-engineering/types";
|
|
7351
|
+
var BCRYPT_ROUNDS = 12;
|
|
7352
|
+
var LEGACY_ENV_ID = "tok_legacy_env";
|
|
7353
|
+
function genId() {
|
|
7354
|
+
return `tok_${randomBytes3(8).toString("hex")}`;
|
|
7355
|
+
}
|
|
7356
|
+
function genSecret() {
|
|
7357
|
+
return randomBytes3(24).toString("base64url");
|
|
7358
|
+
}
|
|
7359
|
+
function parseToken(raw) {
|
|
7360
|
+
const dot = raw.indexOf(".");
|
|
7361
|
+
if (dot < 0) return null;
|
|
7362
|
+
return { id: raw.slice(0, dot), secret: raw.slice(dot + 1) };
|
|
7363
|
+
}
|
|
7364
|
+
var TokenStore = class {
|
|
7365
|
+
constructor(path17) {
|
|
7366
|
+
this.path = path17;
|
|
7367
|
+
}
|
|
7368
|
+
path;
|
|
7369
|
+
cache = null;
|
|
7370
|
+
async load() {
|
|
7371
|
+
if (this.cache) return this.cache;
|
|
7372
|
+
try {
|
|
7373
|
+
const raw = await readFile8(this.path, "utf8");
|
|
7374
|
+
const parsed = JSON.parse(raw);
|
|
7375
|
+
const list = Array.isArray(parsed) ? parsed : [];
|
|
7376
|
+
this.cache = list.map((entry) => {
|
|
7377
|
+
const r = AuthTokenSchema.safeParse(entry);
|
|
7378
|
+
return r.success ? r.data : null;
|
|
7379
|
+
}).filter((x) => x !== null);
|
|
7380
|
+
} catch (err) {
|
|
7381
|
+
if (err.code === "ENOENT") this.cache = [];
|
|
7382
|
+
else throw err;
|
|
7383
|
+
}
|
|
7384
|
+
return this.cache;
|
|
7385
|
+
}
|
|
7386
|
+
async persist(records) {
|
|
7387
|
+
await mkdir7(dirname4(this.path), { recursive: true });
|
|
7388
|
+
const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}-${randomBytes3(4).toString("hex")}`;
|
|
7389
|
+
await writeFile8(tmp, JSON.stringify(records, null, 2), "utf8");
|
|
7390
|
+
await rename2(tmp, this.path);
|
|
7391
|
+
this.cache = records;
|
|
7392
|
+
}
|
|
7393
|
+
async create(input) {
|
|
7394
|
+
const id = genId();
|
|
7395
|
+
const secret = genSecret();
|
|
7396
|
+
const hashedSecret = await bcrypt.hash(secret, BCRYPT_ROUNDS);
|
|
7397
|
+
const record = {
|
|
7398
|
+
id,
|
|
7399
|
+
name: input.name,
|
|
7400
|
+
scopes: input.scopes,
|
|
7401
|
+
...input.bridgeKind ? { bridgeKind: input.bridgeKind } : {},
|
|
7402
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
7403
|
+
hashedSecret,
|
|
7404
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7405
|
+
...input.expiresAt ? { expiresAt: input.expiresAt } : {}
|
|
7406
|
+
};
|
|
7407
|
+
const records = await this.load();
|
|
7408
|
+
if (records.some((r) => r.name === input.name)) {
|
|
7409
|
+
throw new Error(`Token with name "${input.name}" already exists`);
|
|
7410
|
+
}
|
|
7411
|
+
await this.persist([...records, record]);
|
|
7412
|
+
return { id, token: `${id}.${secret}`, record };
|
|
7413
|
+
}
|
|
7414
|
+
async verify(raw) {
|
|
7415
|
+
const parsed = parseToken(raw);
|
|
7416
|
+
if (!parsed) return null;
|
|
7417
|
+
const records = await this.load();
|
|
7418
|
+
const rec = records.find((r) => r.id === parsed.id);
|
|
7419
|
+
if (!rec) return null;
|
|
7420
|
+
if (rec.expiresAt && Date.parse(rec.expiresAt) <= Date.now()) return null;
|
|
7421
|
+
const ok = await bcrypt.compare(parsed.secret, rec.hashedSecret);
|
|
7422
|
+
if (!ok) return null;
|
|
7423
|
+
await this.touchLastUsed(rec.id);
|
|
7424
|
+
return rec;
|
|
7425
|
+
}
|
|
7426
|
+
async touchLastUsed(id) {
|
|
7427
|
+
const records = await this.load();
|
|
7428
|
+
const idx = records.findIndex((r) => r.id === id);
|
|
7429
|
+
if (idx < 0) return;
|
|
7430
|
+
const current = records[idx];
|
|
7431
|
+
if (!current) return;
|
|
7432
|
+
const next = { ...current, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
7433
|
+
const out = records.slice();
|
|
7434
|
+
out[idx] = next;
|
|
7435
|
+
await this.persist(out);
|
|
7436
|
+
}
|
|
7437
|
+
async list() {
|
|
7438
|
+
const records = await this.load();
|
|
7439
|
+
return records.map((r) => AuthTokenPublicSchema2.parse(r));
|
|
7440
|
+
}
|
|
7441
|
+
async revoke(id) {
|
|
7442
|
+
const records = await this.load();
|
|
7443
|
+
const next = records.filter((r) => r.id !== id);
|
|
7444
|
+
if (next.length === records.length) return false;
|
|
7445
|
+
await this.persist(next);
|
|
7446
|
+
return true;
|
|
7447
|
+
}
|
|
7448
|
+
/**
|
|
7449
|
+
* Synthetic admin record for the legacy HARNESS_API_TOKEN escape hatch.
|
|
7450
|
+
* Returned only when `presented` matches `envValue` byte-for-byte (constant-time).
|
|
7451
|
+
*/
|
|
7452
|
+
legacyEnvToken(presented, envValue) {
|
|
7453
|
+
if (!envValue) return null;
|
|
7454
|
+
const a = Buffer.from(presented);
|
|
7455
|
+
const b = Buffer.from(envValue);
|
|
7456
|
+
if (a.length !== b.length) return null;
|
|
7457
|
+
if (!timingSafeEqual(a, b)) return null;
|
|
7458
|
+
return {
|
|
7459
|
+
id: LEGACY_ENV_ID,
|
|
7460
|
+
name: "legacy-env",
|
|
7461
|
+
scopes: ["admin"],
|
|
7462
|
+
hashedSecret: "<env>",
|
|
7463
|
+
createdAt: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
7464
|
+
};
|
|
7465
|
+
}
|
|
7466
|
+
};
|
|
7467
|
+
|
|
7468
|
+
// src/auth/audit.ts
|
|
7469
|
+
import { appendFile, mkdir as mkdir8 } from "fs/promises";
|
|
7470
|
+
import { dirname as dirname5 } from "path";
|
|
7471
|
+
import { AuthAuditEntrySchema } from "@harness-engineering/types";
|
|
7472
|
+
var AuditLogger = class {
|
|
7473
|
+
constructor(path17, opts = {}) {
|
|
7474
|
+
this.path = path17;
|
|
7475
|
+
this.opts = opts;
|
|
7476
|
+
}
|
|
7477
|
+
path;
|
|
7478
|
+
opts;
|
|
7479
|
+
queue = Promise.resolve();
|
|
7480
|
+
dirEnsured = false;
|
|
7481
|
+
async append(input) {
|
|
7482
|
+
const entry = AuthAuditEntrySchema.parse({
|
|
7483
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7484
|
+
tokenId: input.tokenId,
|
|
7485
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
7486
|
+
route: input.route,
|
|
7487
|
+
method: input.method,
|
|
7488
|
+
status: input.status
|
|
7489
|
+
});
|
|
7490
|
+
const line = `${JSON.stringify(entry)}
|
|
7491
|
+
`;
|
|
7492
|
+
this.queue = this.queue.then(() => this.writeLine(line)).catch(() => void 0);
|
|
7493
|
+
}
|
|
7494
|
+
/** Wait for queued writes to drain. Test-only; not called on the hot path. */
|
|
7495
|
+
async flush() {
|
|
7496
|
+
await this.queue;
|
|
7497
|
+
}
|
|
7498
|
+
async writeLine(line) {
|
|
7499
|
+
try {
|
|
7500
|
+
if (this.opts.createDir !== false && !this.dirEnsured) {
|
|
7501
|
+
await mkdir8(dirname5(this.path), { recursive: true });
|
|
7502
|
+
this.dirEnsured = true;
|
|
7503
|
+
}
|
|
7504
|
+
await appendFile(this.path, line, "utf8");
|
|
7505
|
+
} catch (err) {
|
|
7506
|
+
console.warn(`[audit] write failed: ${err.message}`);
|
|
7507
|
+
}
|
|
7508
|
+
}
|
|
7509
|
+
};
|
|
7510
|
+
|
|
7511
|
+
// src/server/v1-bridge-routes.ts
|
|
7512
|
+
var V1_BRIDGE_ROUTES = [
|
|
7513
|
+
// ── Phase 2 bridge primitives ──
|
|
7514
|
+
{
|
|
7515
|
+
method: "POST",
|
|
7516
|
+
pattern: /^\/api\/v1\/jobs\/maintenance(?:\?.*)?$/,
|
|
7517
|
+
scope: "trigger-job",
|
|
7518
|
+
description: "Trigger a maintenance task ad-hoc."
|
|
7519
|
+
},
|
|
7520
|
+
{
|
|
7521
|
+
method: "POST",
|
|
7522
|
+
pattern: /^\/api\/v1\/interactions\/[^/]+\/resolve(?:\?.*)?$/,
|
|
7523
|
+
scope: "resolve-interaction",
|
|
7524
|
+
description: "Resolve a pending interaction."
|
|
7525
|
+
},
|
|
7526
|
+
{
|
|
7527
|
+
method: "GET",
|
|
7528
|
+
pattern: /^\/api\/v1\/events(?:\?.*)?$/,
|
|
7529
|
+
scope: "read-telemetry",
|
|
7530
|
+
description: "Server-Sent Events stream."
|
|
7531
|
+
},
|
|
7532
|
+
// ── Phase 3 bridge primitives ──
|
|
7533
|
+
{
|
|
7534
|
+
method: "POST",
|
|
7535
|
+
pattern: /^\/api\/v1\/webhooks(?:\?.*)?$/,
|
|
7536
|
+
scope: "subscribe-webhook",
|
|
7537
|
+
description: "Subscribe to outbound webhook fan-out."
|
|
7538
|
+
},
|
|
7539
|
+
{
|
|
7540
|
+
method: "DELETE",
|
|
7541
|
+
pattern: /^\/api\/v1\/webhooks\/[^/]+(?:\?.*)?$/,
|
|
7542
|
+
scope: "subscribe-webhook",
|
|
7543
|
+
description: "Delete a webhook subscription."
|
|
7544
|
+
},
|
|
7545
|
+
{
|
|
7546
|
+
method: "GET",
|
|
7547
|
+
pattern: /^\/api\/v1\/webhooks(?:\?.*)?$/,
|
|
7548
|
+
scope: "subscribe-webhook",
|
|
7549
|
+
description: "List webhook subscriptions."
|
|
7550
|
+
},
|
|
7551
|
+
// ── Phase 4 bridge primitives ──
|
|
7552
|
+
{
|
|
7553
|
+
method: "GET",
|
|
7554
|
+
pattern: /^\/api\/v1\/webhooks\/queue\/stats(?:\?.*)?$/,
|
|
7555
|
+
scope: "subscribe-webhook",
|
|
7556
|
+
description: "Webhook delivery queue depth + DLQ stats."
|
|
7557
|
+
},
|
|
7558
|
+
// ── Phase 5 bridge primitives ──
|
|
7559
|
+
{
|
|
7560
|
+
method: "GET",
|
|
7561
|
+
pattern: /^\/api\/v1\/telemetry\/cache\/stats(?:\?.*)?$/,
|
|
7562
|
+
scope: "read-telemetry",
|
|
7563
|
+
description: "Prompt-cache hit/miss snapshot (rolling window)."
|
|
7564
|
+
}
|
|
7565
|
+
];
|
|
7566
|
+
function isV1Bridge(method, url) {
|
|
7567
|
+
return V1_BRIDGE_ROUTES.some((r) => r.method === method && r.pattern.test(url));
|
|
7568
|
+
}
|
|
7569
|
+
function requiredBridgeScope(method, path17) {
|
|
7570
|
+
for (const r of V1_BRIDGE_ROUTES) {
|
|
7571
|
+
if (r.method === method && r.pattern.test(path17)) return r.scope;
|
|
7572
|
+
}
|
|
7573
|
+
return null;
|
|
7574
|
+
}
|
|
7575
|
+
|
|
7576
|
+
// src/auth/scopes.ts
|
|
7577
|
+
function hasScope(held, required) {
|
|
7578
|
+
if (held.includes("admin")) return true;
|
|
7579
|
+
return held.includes(required);
|
|
7580
|
+
}
|
|
7581
|
+
function requiredScopeForRoute(method, path17) {
|
|
7582
|
+
const bridgeScope = requiredBridgeScope(method, path17);
|
|
7583
|
+
if (bridgeScope) return bridgeScope;
|
|
7584
|
+
if (path17 === "/api/v1/auth/token" && method === "POST") return "admin";
|
|
7585
|
+
if (path17 === "/api/v1/auth/tokens" && method === "GET") return "admin";
|
|
7586
|
+
if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path17) && method === "DELETE") return "admin";
|
|
7587
|
+
if ((path17 === "/api/state" || path17 === "/api/v1/state") && method === "GET") return "read-status";
|
|
7588
|
+
if (path17.startsWith("/api/interactions")) return "resolve-interaction";
|
|
7589
|
+
if (path17.startsWith("/api/plans")) return "read-status";
|
|
7590
|
+
if (path17.startsWith("/api/analyze") || path17.startsWith("/api/analyses")) return "read-status";
|
|
7591
|
+
if (path17.startsWith("/api/roadmap-actions")) return "modify-roadmap";
|
|
7592
|
+
if (path17.startsWith("/api/dispatch-actions")) return "trigger-job";
|
|
7593
|
+
if (path17.startsWith("/api/local-model") || path17.startsWith("/api/local-models"))
|
|
7594
|
+
return "read-status";
|
|
7595
|
+
if (path17.startsWith("/api/maintenance")) return "trigger-job";
|
|
7596
|
+
if (path17.startsWith("/api/streams")) return "read-status";
|
|
7597
|
+
if (path17.startsWith("/api/sessions")) return "read-status";
|
|
7598
|
+
if (path17.startsWith("/api/chat-proxy")) return "trigger-job";
|
|
7599
|
+
return null;
|
|
7600
|
+
}
|
|
7601
|
+
|
|
6856
7602
|
// src/server/http.ts
|
|
6857
7603
|
var RATE_LIMIT = Number(process.env["HARNESS_RATE_LIMIT"]) || 100;
|
|
6858
7604
|
var WINDOW_MS = 6e4;
|
|
6859
7605
|
var rateBuckets = /* @__PURE__ */ new Map();
|
|
7606
|
+
var DEPRECATION_DATE = process.env["HARNESS_DEPRECATION_DATE"] ?? "2027-05-14";
|
|
6860
7607
|
var ratePruneTimer = setInterval(() => {
|
|
6861
7608
|
const now = Date.now();
|
|
6862
7609
|
for (const [ip, bucket] of rateBuckets) {
|
|
@@ -6905,8 +7652,13 @@ var OrchestratorServer = class {
|
|
|
6905
7652
|
maintenanceDeps = null;
|
|
6906
7653
|
getLocalModelStatus = null;
|
|
6907
7654
|
getLocalModelStatuses = null;
|
|
7655
|
+
webhooks;
|
|
7656
|
+
cacheMetrics;
|
|
6908
7657
|
recorder = null;
|
|
6909
7658
|
planWatcher = null;
|
|
7659
|
+
tokenStore;
|
|
7660
|
+
auditLogger;
|
|
7661
|
+
warnedUnauthDev = false;
|
|
6910
7662
|
stateChangeListener;
|
|
6911
7663
|
agentEventListener;
|
|
6912
7664
|
apiRoutes;
|
|
@@ -6914,6 +7666,10 @@ var OrchestratorServer = class {
|
|
|
6914
7666
|
this.orchestrator = orchestrator;
|
|
6915
7667
|
this.port = port;
|
|
6916
7668
|
this.initDependencies(deps);
|
|
7669
|
+
const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path14.resolve(".harness", "tokens.json");
|
|
7670
|
+
const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path14.resolve(".harness", "audit.log");
|
|
7671
|
+
this.tokenStore = new TokenStore(tokensPath);
|
|
7672
|
+
this.auditLogger = new AuditLogger(auditPath);
|
|
6917
7673
|
this.httpServer = http.createServer(this.handleRequest.bind(this));
|
|
6918
7674
|
this.broadcaster = new WebSocketBroadcaster(
|
|
6919
7675
|
this.httpServer,
|
|
@@ -6935,6 +7691,8 @@ var OrchestratorServer = class {
|
|
|
6935
7691
|
this.maintenanceDeps = deps?.maintenanceDeps ?? null;
|
|
6936
7692
|
this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
|
|
6937
7693
|
this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
|
|
7694
|
+
this.webhooks = deps?.webhooks;
|
|
7695
|
+
this.cacheMetrics = deps?.cacheMetrics;
|
|
6938
7696
|
}
|
|
6939
7697
|
wireEvents() {
|
|
6940
7698
|
this.stateChangeListener = (snapshot) => {
|
|
@@ -7000,10 +7758,8 @@ var OrchestratorServer = class {
|
|
|
7000
7758
|
this.recorder = recorder;
|
|
7001
7759
|
}
|
|
7002
7760
|
handleRequest(req, res) {
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
}
|
|
7006
|
-
if (!checkRateLimit(req, res)) {
|
|
7761
|
+
const isState = req.method === "GET" && (req.url === "/api/state" || req.url === "/api/v1/state");
|
|
7762
|
+
if (!isState && !checkRateLimit(req, res)) {
|
|
7007
7763
|
return;
|
|
7008
7764
|
}
|
|
7009
7765
|
if (this.handleApiRoutes(req, res)) {
|
|
@@ -7015,31 +7771,48 @@ var OrchestratorServer = class {
|
|
|
7015
7771
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
7016
7772
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
7017
7773
|
}
|
|
7018
|
-
/** Handle GET /api/state and legacy /api/v1/state */
|
|
7019
|
-
handleStateEndpoint(req, res) {
|
|
7020
|
-
const { method, url } = req;
|
|
7021
|
-
if (method === "GET" && (url === "/api/state" || url === "/api/v1/state")) {
|
|
7022
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
7023
|
-
res.end(JSON.stringify(this.orchestrator.getSnapshot()));
|
|
7024
|
-
return true;
|
|
7025
|
-
}
|
|
7026
|
-
return false;
|
|
7027
|
-
}
|
|
7028
7774
|
/**
|
|
7029
|
-
*
|
|
7030
|
-
*
|
|
7031
|
-
*
|
|
7775
|
+
* Phase 1 auth: bearer token lookup against TokenStore + scope check.
|
|
7776
|
+
* Legacy HARNESS_API_TOKEN env var still authenticates as a synthetic
|
|
7777
|
+
* admin record (see TokenStore.legacyEnvToken).
|
|
7778
|
+
*
|
|
7779
|
+
* Returns the resolved AuthToken on success; sends 401/403 + returns null on failure.
|
|
7032
7780
|
*/
|
|
7033
|
-
|
|
7034
|
-
const token = process.env["HARNESS_API_TOKEN"];
|
|
7035
|
-
if (!token) return true;
|
|
7781
|
+
async resolveAuth(req, res) {
|
|
7036
7782
|
const authHeader = req.headers["authorization"];
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7783
|
+
const legacyEnv = process.env["HARNESS_API_TOKEN"];
|
|
7784
|
+
const listed = await this.tokenStore.list().catch(() => []);
|
|
7785
|
+
if (listed.length === 0 && !legacyEnv) {
|
|
7786
|
+
res.setHeader("X-Harness-Auth-Mode", "unauth-dev");
|
|
7787
|
+
if (!this.warnedUnauthDev) {
|
|
7788
|
+
this.warnedUnauthDev = true;
|
|
7789
|
+
console.warn(
|
|
7790
|
+
"harness orchestrator: running in UNAUTHENTICATED dev mode (tokens.json empty and HARNESS_API_TOKEN not set). All requests resolve as admin. Configure tokens before exposing the API beyond localhost."
|
|
7791
|
+
);
|
|
7792
|
+
}
|
|
7793
|
+
return {
|
|
7794
|
+
id: "tok_unauth_dev",
|
|
7795
|
+
name: "unauth-dev",
|
|
7796
|
+
scopes: ["admin"],
|
|
7797
|
+
hashedSecret: "<none>",
|
|
7798
|
+
createdAt: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
7799
|
+
};
|
|
7800
|
+
}
|
|
7801
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
7802
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
7803
|
+
res.end(JSON.stringify({ error: "Unauthorized \u2014 set Authorization: Bearer <token>" }));
|
|
7804
|
+
return null;
|
|
7805
|
+
}
|
|
7806
|
+
const raw = authHeader.slice("Bearer ".length).trim();
|
|
7807
|
+
const legacyMatch = this.tokenStore.legacyEnvToken(raw, legacyEnv);
|
|
7808
|
+
if (legacyMatch) return legacyMatch;
|
|
7809
|
+
const verified = await this.tokenStore.verify(raw);
|
|
7810
|
+
if (!verified) {
|
|
7811
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
7812
|
+
res.end(JSON.stringify({ error: "Unauthorized \u2014 invalid or expired token" }));
|
|
7813
|
+
return null;
|
|
7814
|
+
}
|
|
7815
|
+
return verified;
|
|
7043
7816
|
}
|
|
7044
7817
|
/**
|
|
7045
7818
|
* Build the ordered API route table. Each entry is invoked in order and
|
|
@@ -7051,7 +7824,11 @@ var OrchestratorServer = class {
|
|
|
7051
7824
|
*/
|
|
7052
7825
|
buildApiRoutes() {
|
|
7053
7826
|
return [
|
|
7827
|
+
// Auth admin routes — scope-gated to `admin` by requiredScopeForRoute.
|
|
7828
|
+
// First in the table so the auth surface is unambiguously owned by the orchestrator.
|
|
7829
|
+
(req, res) => handleAuthRoute(req, res, this.tokenStore),
|
|
7054
7830
|
(req, res) => !!this.interactionQueue && handleInteractionsRoute(req, res, this.interactionQueue),
|
|
7831
|
+
(req, res) => !!this.interactionQueue && handleV1InteractionsResolveRoute(req, res, this.interactionQueue),
|
|
7055
7832
|
(req, res) => handlePlansRoute(req, res, this.plansDir),
|
|
7056
7833
|
(req, res) => handleAnalyzeRoute(req, res, this.pipeline),
|
|
7057
7834
|
(req, res) => handleAnalysesRoute(req, res, this.analysisArchive),
|
|
@@ -7061,19 +7838,103 @@ var OrchestratorServer = class {
|
|
|
7061
7838
|
// Local-models multi-status route (Spec 2 SC38)
|
|
7062
7839
|
(req, res) => handleLocalModelsRoute(req, res, this.getLocalModelStatuses),
|
|
7063
7840
|
(req, res) => handleMaintenanceRoute(req, res, this.maintenanceDeps),
|
|
7841
|
+
(req, res) => handleV1JobsMaintenanceRoute(req, res, this.maintenanceDeps),
|
|
7064
7842
|
(req, res) => !!this.recorder && handleStreamsRoute(req, res, this.recorder),
|
|
7065
7843
|
(req, res) => handleSessionsRoute(req, res, this.sessionsDir),
|
|
7844
|
+
// SSE event stream — long-lived; placed near end so cheaper routes
|
|
7845
|
+
// short-circuit first, but before the chat-proxy fallback.
|
|
7846
|
+
(req, res) => handleV1EventsSseRoute(req, res, this.orchestrator),
|
|
7847
|
+
// Phase 3 webhooks — short-circuits to false when webhooks is undefined
|
|
7848
|
+
// (e.g. FakeOrchestrator-based tests pass no webhooks dep).
|
|
7849
|
+
// Phase 4: forward the optional queue handle so the stats endpoint can
|
|
7850
|
+
// serve depth/DLQ counts.
|
|
7851
|
+
(req, res) => !!this.webhooks && handleV1WebhooksRoute(req, res, {
|
|
7852
|
+
store: this.webhooks.store,
|
|
7853
|
+
bus: this.orchestrator,
|
|
7854
|
+
...this.webhooks.queue ? { queue: this.webhooks.queue } : {}
|
|
7855
|
+
}),
|
|
7856
|
+
// Phase 5 — telemetry/cache/stats. Returns 503 when cacheMetrics is unset
|
|
7857
|
+
// (FakeOrchestrator tests, exporter-disabled configs).
|
|
7858
|
+
(req, res) => handleV1TelemetryRoute(req, res, {
|
|
7859
|
+
...this.cacheMetrics ? { cacheMetrics: this.cacheMetrics } : {}
|
|
7860
|
+
}),
|
|
7066
7861
|
// Chat proxy route (spawns Claude Code CLI — no API key required)
|
|
7067
7862
|
(req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
|
|
7068
7863
|
];
|
|
7069
7864
|
}
|
|
7070
|
-
/**
|
|
7865
|
+
/**
|
|
7866
|
+
* Dispatch to API route handlers. Returns true immediately and resolves the
|
|
7867
|
+
* request asynchronously (auth + scope check + dispatch + audit log).
|
|
7868
|
+
*
|
|
7869
|
+
* Static-file fallback for non-/api/* requests requires returning false so
|
|
7870
|
+
* `handleRequest` can hand the request off to the static handler.
|
|
7871
|
+
*/
|
|
7071
7872
|
handleApiRoutes(req, res) {
|
|
7072
|
-
|
|
7873
|
+
const url = req.url ?? "";
|
|
7874
|
+
if (!url.startsWith("/api/")) return false;
|
|
7875
|
+
void this.dispatchAuthedRequest(req, res);
|
|
7876
|
+
return true;
|
|
7877
|
+
}
|
|
7878
|
+
async dispatchAuthedRequest(req, res) {
|
|
7879
|
+
const token = await this.resolveAuth(req, res);
|
|
7880
|
+
res.on("finish", () => this.audit(req, res, token));
|
|
7881
|
+
if (!token) return;
|
|
7882
|
+
req._authToken = {
|
|
7883
|
+
id: token.id,
|
|
7884
|
+
scopes: token.scopes
|
|
7885
|
+
};
|
|
7886
|
+
const V1_WRAPPABLE = /* @__PURE__ */ new Set([
|
|
7887
|
+
"interactions",
|
|
7888
|
+
"plans",
|
|
7889
|
+
"analyze",
|
|
7890
|
+
"analyses",
|
|
7891
|
+
"roadmap-actions",
|
|
7892
|
+
"dispatch-actions",
|
|
7893
|
+
"local-model",
|
|
7894
|
+
"local-models",
|
|
7895
|
+
"maintenance",
|
|
7896
|
+
"streams",
|
|
7897
|
+
"sessions",
|
|
7898
|
+
"chat-proxy"
|
|
7899
|
+
]);
|
|
7900
|
+
const v1Match = /^\/api\/v1\/([^/?]+)(.*)$/.exec(req.url ?? "");
|
|
7901
|
+
const rewrittenSlug = v1Match?.[1];
|
|
7902
|
+
const v1BridgeMatch = isV1Bridge(req.method ?? "GET", req.url ?? "");
|
|
7903
|
+
if (!v1BridgeMatch && rewrittenSlug && V1_WRAPPABLE.has(rewrittenSlug)) {
|
|
7904
|
+
req.url = `/api/${rewrittenSlug}${v1Match?.[2] ?? ""}`;
|
|
7905
|
+
}
|
|
7906
|
+
const isLegacyPrefix = !!req.url && // eslint-disable-next-line @harness-engineering/no-hardcoded-path-separator -- URL path, not filesystem
|
|
7907
|
+
req.url.startsWith("/api/") && // eslint-disable-next-line @harness-engineering/no-hardcoded-path-separator -- URL path, not filesystem
|
|
7908
|
+
!req.url.startsWith("/api/v1/") && !v1Match;
|
|
7909
|
+
if (isLegacyPrefix) {
|
|
7910
|
+
res.setHeader("Deprecation", DEPRECATION_DATE);
|
|
7911
|
+
}
|
|
7912
|
+
const pathname = (req.url ?? "").split("?")[0] ?? "";
|
|
7913
|
+
const required = requiredScopeForRoute(req.method ?? "GET", pathname);
|
|
7914
|
+
if (!required || !hasScope(token.scopes, required)) {
|
|
7915
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
7916
|
+
res.end(JSON.stringify({ error: "Insufficient scope", required: required ?? "unknown" }));
|
|
7917
|
+
return;
|
|
7918
|
+
}
|
|
7919
|
+
if (req.method === "GET" && (req.url === "/api/state" || req.url === "/api/v1/state")) {
|
|
7920
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
7921
|
+
res.end(JSON.stringify(this.orchestrator.getSnapshot()));
|
|
7922
|
+
return;
|
|
7923
|
+
}
|
|
7073
7924
|
for (const route of this.apiRoutes) {
|
|
7074
|
-
if (route(req, res)) return
|
|
7925
|
+
if (route(req, res)) return;
|
|
7075
7926
|
}
|
|
7076
|
-
|
|
7927
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
7928
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
7929
|
+
}
|
|
7930
|
+
audit(req, res, token) {
|
|
7931
|
+
void this.auditLogger.append({
|
|
7932
|
+
tokenId: token?.id ?? "anonymous",
|
|
7933
|
+
...token?.tenantId ? { tenantId: token.tenantId } : {},
|
|
7934
|
+
route: (req.url ?? "").split("?")[0] ?? "",
|
|
7935
|
+
method: req.method ?? "GET",
|
|
7936
|
+
status: res.statusCode || 0
|
|
7937
|
+
});
|
|
7077
7938
|
}
|
|
7078
7939
|
get wsClientCount() {
|
|
7079
7940
|
return this.broadcaster.clientCount;
|
|
@@ -7104,6 +7965,630 @@ var OrchestratorServer = class {
|
|
|
7104
7965
|
}
|
|
7105
7966
|
};
|
|
7106
7967
|
|
|
7968
|
+
// src/gateway/webhooks/store.ts
|
|
7969
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
7970
|
+
import { readFile as readFile9, writeFile as writeFile9, mkdir as mkdir9, rename as rename3, chmod } from "fs/promises";
|
|
7971
|
+
import { dirname as dirname6 } from "path";
|
|
7972
|
+
import { WebhookSubscriptionSchema } from "@harness-engineering/types";
|
|
7973
|
+
|
|
7974
|
+
// src/gateway/webhooks/signer.ts
|
|
7975
|
+
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
7976
|
+
function sign(secret, body) {
|
|
7977
|
+
const hex = createHmac("sha256", secret).update(body).digest("hex");
|
|
7978
|
+
return `sha256=${hex}`;
|
|
7979
|
+
}
|
|
7980
|
+
function eventMatches(pattern, type) {
|
|
7981
|
+
if (type.startsWith("telemetry.")) {
|
|
7982
|
+
const pSegs2 = pattern.split(".");
|
|
7983
|
+
if (pSegs2[0] !== "telemetry") return false;
|
|
7984
|
+
}
|
|
7985
|
+
const pSegs = pattern.split(".");
|
|
7986
|
+
const tSegs = type.split(".");
|
|
7987
|
+
if (pSegs.length !== tSegs.length) return false;
|
|
7988
|
+
for (let i = 0; i < pSegs.length; i++) {
|
|
7989
|
+
if (pSegs[i] !== "*" && pSegs[i] !== tSegs[i]) return false;
|
|
7990
|
+
}
|
|
7991
|
+
return true;
|
|
7992
|
+
}
|
|
7993
|
+
|
|
7994
|
+
// src/gateway/webhooks/store.ts
|
|
7995
|
+
function genId2() {
|
|
7996
|
+
return `whk_${randomBytes4(8).toString("hex")}`;
|
|
7997
|
+
}
|
|
7998
|
+
function genSecret2() {
|
|
7999
|
+
return randomBytes4(32).toString("base64url");
|
|
8000
|
+
}
|
|
8001
|
+
var WebhookStore = class {
|
|
8002
|
+
constructor(path17) {
|
|
8003
|
+
this.path = path17;
|
|
8004
|
+
}
|
|
8005
|
+
path;
|
|
8006
|
+
cache = null;
|
|
8007
|
+
async load() {
|
|
8008
|
+
if (this.cache) return this.cache;
|
|
8009
|
+
try {
|
|
8010
|
+
const raw = await readFile9(this.path, "utf8");
|
|
8011
|
+
const parsed = JSON.parse(raw);
|
|
8012
|
+
const list = Array.isArray(parsed) ? parsed : [];
|
|
8013
|
+
this.cache = list.map((entry) => {
|
|
8014
|
+
const r = WebhookSubscriptionSchema.safeParse(entry);
|
|
8015
|
+
return r.success ? r.data : null;
|
|
8016
|
+
}).filter((x) => x !== null);
|
|
8017
|
+
} catch (err) {
|
|
8018
|
+
if (err.code === "ENOENT") this.cache = [];
|
|
8019
|
+
else throw err;
|
|
8020
|
+
}
|
|
8021
|
+
return this.cache;
|
|
8022
|
+
}
|
|
8023
|
+
async persist(records) {
|
|
8024
|
+
const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}-${randomBytes4(4).toString("hex")}`;
|
|
8025
|
+
try {
|
|
8026
|
+
await mkdir9(dirname6(this.path), { recursive: true });
|
|
8027
|
+
await writeFile9(tmp, JSON.stringify(records, null, 2), { encoding: "utf8", mode: 384 });
|
|
8028
|
+
await rename3(tmp, this.path);
|
|
8029
|
+
await chmod(this.path, 384);
|
|
8030
|
+
} catch (err) {
|
|
8031
|
+
if (err.code !== "ENOENT") throw err;
|
|
8032
|
+
}
|
|
8033
|
+
this.cache = records;
|
|
8034
|
+
}
|
|
8035
|
+
async create(input) {
|
|
8036
|
+
const id = genId2();
|
|
8037
|
+
const secret = genSecret2();
|
|
8038
|
+
const record = {
|
|
8039
|
+
id,
|
|
8040
|
+
tokenId: input.tokenId,
|
|
8041
|
+
url: input.url,
|
|
8042
|
+
events: input.events,
|
|
8043
|
+
secret,
|
|
8044
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
8045
|
+
};
|
|
8046
|
+
const records = await this.load();
|
|
8047
|
+
await this.persist([...records, record]);
|
|
8048
|
+
return record;
|
|
8049
|
+
}
|
|
8050
|
+
async list() {
|
|
8051
|
+
return [...await this.load()];
|
|
8052
|
+
}
|
|
8053
|
+
async delete(id) {
|
|
8054
|
+
const records = await this.load();
|
|
8055
|
+
const next = records.filter((r) => r.id !== id);
|
|
8056
|
+
if (next.length === records.length) return false;
|
|
8057
|
+
await this.persist(next);
|
|
8058
|
+
return true;
|
|
8059
|
+
}
|
|
8060
|
+
/** Returns subs whose events list contains a pattern matching `eventType`. */
|
|
8061
|
+
async listForEvent(eventType) {
|
|
8062
|
+
const records = await this.load();
|
|
8063
|
+
return records.filter((r) => r.events.some((p) => eventMatches(p, eventType)));
|
|
8064
|
+
}
|
|
8065
|
+
};
|
|
8066
|
+
|
|
8067
|
+
// src/gateway/webhooks/delivery.ts
|
|
8068
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
8069
|
+
|
|
8070
|
+
// src/gateway/webhooks/queue.ts
|
|
8071
|
+
import Database from "better-sqlite3";
|
|
8072
|
+
var RETRY_DELAYS_MS = [1e3, 4e3, 16e3, 64e3, 256e3];
|
|
8073
|
+
var MAX_ATTEMPTS = 5;
|
|
8074
|
+
var SCHEMA_SQL = `
|
|
8075
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
8076
|
+
id TEXT PRIMARY KEY,
|
|
8077
|
+
subscriptionId TEXT NOT NULL,
|
|
8078
|
+
eventType TEXT NOT NULL,
|
|
8079
|
+
payload TEXT NOT NULL,
|
|
8080
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
8081
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
8082
|
+
nextAttemptAt INTEGER,
|
|
8083
|
+
lastError TEXT,
|
|
8084
|
+
deliveredAt INTEGER
|
|
8085
|
+
) STRICT;
|
|
8086
|
+
CREATE INDEX IF NOT EXISTS idx_deliverable
|
|
8087
|
+
ON webhook_deliveries(status, nextAttemptAt)
|
|
8088
|
+
WHERE status IN ('pending', 'failed', 'in_flight');
|
|
8089
|
+
`;
|
|
8090
|
+
var WebhookQueue = class {
|
|
8091
|
+
db;
|
|
8092
|
+
constructor(dbPath) {
|
|
8093
|
+
this.db = new Database(dbPath);
|
|
8094
|
+
this.db.pragma("journal_mode = WAL");
|
|
8095
|
+
this.db.pragma("synchronous = NORMAL");
|
|
8096
|
+
this.db.exec(SCHEMA_SQL);
|
|
8097
|
+
this.recoverInFlight();
|
|
8098
|
+
}
|
|
8099
|
+
insert(row) {
|
|
8100
|
+
this.db.prepare(
|
|
8101
|
+
`INSERT INTO webhook_deliveries
|
|
8102
|
+
(id, subscriptionId, eventType, payload, attempt, status, nextAttemptAt)
|
|
8103
|
+
VALUES (@id, @subscriptionId, @eventType, @payload, 0, 'pending', @nextAttemptAt)`
|
|
8104
|
+
).run({ ...row, nextAttemptAt: Date.now() });
|
|
8105
|
+
}
|
|
8106
|
+
/**
|
|
8107
|
+
* Atomically lease a batch of deliverable rows: select pending|failed rows
|
|
8108
|
+
* whose nextAttemptAt has elapsed, mark them in_flight in the same
|
|
8109
|
+
* transaction, and return the leased rows. Subsequent calls cannot re-claim
|
|
8110
|
+
* the same rows because they are no longer in pending|failed.
|
|
8111
|
+
*
|
|
8112
|
+
* Without this, two overlapping ticks (tick interval 500ms, HTTP timeout
|
|
8113
|
+
* 5s) would both select the same row and double-fire the webhook.
|
|
8114
|
+
*/
|
|
8115
|
+
claim(now, limit = 20) {
|
|
8116
|
+
const selectIds = this.db.prepare(
|
|
8117
|
+
`SELECT id FROM webhook_deliveries
|
|
8118
|
+
WHERE (status = 'pending' OR status = 'failed')
|
|
8119
|
+
AND nextAttemptAt <= ?
|
|
8120
|
+
ORDER BY nextAttemptAt
|
|
8121
|
+
LIMIT ?`
|
|
8122
|
+
);
|
|
8123
|
+
const markInFlight = this.db.prepare(
|
|
8124
|
+
`UPDATE webhook_deliveries SET status = 'in_flight' WHERE id = ?`
|
|
8125
|
+
);
|
|
8126
|
+
const fetchById = this.db.prepare(`SELECT * FROM webhook_deliveries WHERE id = ?`);
|
|
8127
|
+
const txn = this.db.transaction((tNow, tLimit) => {
|
|
8128
|
+
const ids = selectIds.all(tNow, tLimit);
|
|
8129
|
+
const claimed = [];
|
|
8130
|
+
for (const { id } of ids) {
|
|
8131
|
+
markInFlight.run(id);
|
|
8132
|
+
const row = fetchById.get(id);
|
|
8133
|
+
if (row) claimed.push(row);
|
|
8134
|
+
}
|
|
8135
|
+
return claimed;
|
|
8136
|
+
});
|
|
8137
|
+
return txn(now, limit);
|
|
8138
|
+
}
|
|
8139
|
+
/**
|
|
8140
|
+
* Reset any rows stuck in in_flight (e.g. from a crashed or abruptly
|
|
8141
|
+
* stopped worker) back to failed so they can be re-claimed by the next
|
|
8142
|
+
* tick. At-most-once semantics within a single process; at-least-once
|
|
8143
|
+
* across restarts (a row whose HTTP POST completed but whose DB update was
|
|
8144
|
+
* lost will be re-delivered — bridges must be idempotent on delivery-id).
|
|
8145
|
+
*/
|
|
8146
|
+
recoverInFlight() {
|
|
8147
|
+
return this.db.prepare(
|
|
8148
|
+
`UPDATE webhook_deliveries
|
|
8149
|
+
SET status = 'failed', nextAttemptAt = ?
|
|
8150
|
+
WHERE status = 'in_flight'`
|
|
8151
|
+
).run(Date.now()).changes;
|
|
8152
|
+
}
|
|
8153
|
+
markDelivered(id, deliveredAt) {
|
|
8154
|
+
this.db.prepare(
|
|
8155
|
+
`UPDATE webhook_deliveries
|
|
8156
|
+
SET status = 'delivered', deliveredAt = ?, nextAttemptAt = NULL
|
|
8157
|
+
WHERE id = ?`
|
|
8158
|
+
).run(deliveredAt, id);
|
|
8159
|
+
}
|
|
8160
|
+
markFailed(id, attempt, nextAttemptAt, lastError) {
|
|
8161
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
8162
|
+
this.db.prepare(
|
|
8163
|
+
`UPDATE webhook_deliveries
|
|
8164
|
+
SET status = 'dead', attempt = ?, lastError = ?, nextAttemptAt = NULL
|
|
8165
|
+
WHERE id = ?`
|
|
8166
|
+
).run(attempt, lastError, id);
|
|
8167
|
+
} else {
|
|
8168
|
+
this.db.prepare(
|
|
8169
|
+
`UPDATE webhook_deliveries
|
|
8170
|
+
SET status = 'failed', attempt = ?, nextAttemptAt = ?, lastError = ?
|
|
8171
|
+
WHERE id = ?`
|
|
8172
|
+
).run(attempt, nextAttemptAt, lastError, id);
|
|
8173
|
+
}
|
|
8174
|
+
}
|
|
8175
|
+
retryDead(id) {
|
|
8176
|
+
const result = this.db.prepare(
|
|
8177
|
+
`UPDATE webhook_deliveries
|
|
8178
|
+
SET status = 'pending', attempt = 0, nextAttemptAt = ?, lastError = NULL
|
|
8179
|
+
WHERE id = ? AND status = 'dead'`
|
|
8180
|
+
).run(Date.now(), id);
|
|
8181
|
+
return result.changes > 0;
|
|
8182
|
+
}
|
|
8183
|
+
list(filter = {}) {
|
|
8184
|
+
const conditions = ["1=1"];
|
|
8185
|
+
const params = [];
|
|
8186
|
+
if (filter.status) {
|
|
8187
|
+
conditions.push("status = ?");
|
|
8188
|
+
params.push(filter.status);
|
|
8189
|
+
}
|
|
8190
|
+
if (filter.subscriptionId) {
|
|
8191
|
+
conditions.push("subscriptionId = ?");
|
|
8192
|
+
params.push(filter.subscriptionId);
|
|
8193
|
+
}
|
|
8194
|
+
const sql = `SELECT * FROM webhook_deliveries WHERE ${conditions.join(" AND ")} ORDER BY nextAttemptAt DESC LIMIT 200`;
|
|
8195
|
+
return this.db.prepare(sql).all(...params);
|
|
8196
|
+
}
|
|
8197
|
+
purge(opts = {}) {
|
|
8198
|
+
const conditions = ["1=1"];
|
|
8199
|
+
const params = [];
|
|
8200
|
+
if (opts.deadOnly) {
|
|
8201
|
+
conditions.push("status = 'dead'");
|
|
8202
|
+
}
|
|
8203
|
+
if (opts.olderThanMs !== void 0) {
|
|
8204
|
+
const cutoff = Date.now() - opts.olderThanMs;
|
|
8205
|
+
conditions.push("(deliveredAt IS NOT NULL AND deliveredAt < ?)");
|
|
8206
|
+
params.push(cutoff);
|
|
8207
|
+
}
|
|
8208
|
+
const sql = `DELETE FROM webhook_deliveries WHERE ${conditions.join(" AND ")}`;
|
|
8209
|
+
return this.db.prepare(sql).run(...params).changes;
|
|
8210
|
+
}
|
|
8211
|
+
/**
|
|
8212
|
+
* Count the rows a purge() call with these same options would delete.
|
|
8213
|
+
* Used by the CLI to show a confirmation preview before destructive deletes.
|
|
8214
|
+
*/
|
|
8215
|
+
previewPurge(opts = {}) {
|
|
8216
|
+
const conditions = ["1=1"];
|
|
8217
|
+
const params = [];
|
|
8218
|
+
if (opts.deadOnly) {
|
|
8219
|
+
conditions.push("status = 'dead'");
|
|
8220
|
+
}
|
|
8221
|
+
if (opts.olderThanMs !== void 0) {
|
|
8222
|
+
const cutoff = Date.now() - opts.olderThanMs;
|
|
8223
|
+
conditions.push("(deliveredAt IS NOT NULL AND deliveredAt < ?)");
|
|
8224
|
+
params.push(cutoff);
|
|
8225
|
+
}
|
|
8226
|
+
const sql = `SELECT COUNT(*) as count FROM webhook_deliveries WHERE ${conditions.join(" AND ")}`;
|
|
8227
|
+
const row = this.db.prepare(sql).get(...params);
|
|
8228
|
+
return row.count;
|
|
8229
|
+
}
|
|
8230
|
+
stats() {
|
|
8231
|
+
const rows = this.db.prepare(`SELECT status, COUNT(*) as count FROM webhook_deliveries GROUP BY status`).all();
|
|
8232
|
+
const m = Object.fromEntries(rows.map((r) => [r.status, r.count]));
|
|
8233
|
+
return {
|
|
8234
|
+
pending: m["pending"] ?? 0,
|
|
8235
|
+
inFlight: m["in_flight"] ?? 0,
|
|
8236
|
+
failed: m["failed"] ?? 0,
|
|
8237
|
+
dead: m["dead"] ?? 0,
|
|
8238
|
+
delivered: m["delivered"] ?? 0
|
|
8239
|
+
};
|
|
8240
|
+
}
|
|
8241
|
+
close() {
|
|
8242
|
+
this.db.close();
|
|
8243
|
+
}
|
|
8244
|
+
};
|
|
8245
|
+
|
|
8246
|
+
// src/gateway/webhooks/delivery.ts
|
|
8247
|
+
var WebhookDelivery = class {
|
|
8248
|
+
queue;
|
|
8249
|
+
store;
|
|
8250
|
+
timeoutMs;
|
|
8251
|
+
fetchImpl;
|
|
8252
|
+
tickIntervalMs;
|
|
8253
|
+
maxConcurrentPerSub;
|
|
8254
|
+
drainTimeoutMs;
|
|
8255
|
+
allowPrivateHosts;
|
|
8256
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
8257
|
+
/**
|
|
8258
|
+
* AbortControllers for currently executing HTTP POSTs, keyed by delivery id.
|
|
8259
|
+
* On drain-timeout exhaustion stop() aborts each one so we never write to
|
|
8260
|
+
* the SQLite handle after orchestrator.stop() closes it.
|
|
8261
|
+
*/
|
|
8262
|
+
inFlightAborts = /* @__PURE__ */ new Map();
|
|
8263
|
+
tickTimer = null;
|
|
8264
|
+
draining = false;
|
|
8265
|
+
constructor(opts) {
|
|
8266
|
+
this.queue = opts.queue;
|
|
8267
|
+
this.store = opts.store;
|
|
8268
|
+
this.timeoutMs = opts.timeoutMs ?? 5e3;
|
|
8269
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
8270
|
+
this.tickIntervalMs = opts.tickIntervalMs ?? 500;
|
|
8271
|
+
this.maxConcurrentPerSub = opts.maxConcurrentPerSub ?? 4;
|
|
8272
|
+
this.drainTimeoutMs = opts.drainTimeoutMs ?? 3e4;
|
|
8273
|
+
this.allowPrivateHosts = opts.allowPrivateHosts ?? false;
|
|
8274
|
+
}
|
|
8275
|
+
enqueue(sub, event) {
|
|
8276
|
+
const payload = JSON.stringify(event);
|
|
8277
|
+
this.queue.insert({
|
|
8278
|
+
id: `dlv_${randomBytes5(8).toString("hex")}`,
|
|
8279
|
+
subscriptionId: sub.id,
|
|
8280
|
+
eventType: event.type,
|
|
8281
|
+
payload
|
|
8282
|
+
});
|
|
8283
|
+
}
|
|
8284
|
+
start() {
|
|
8285
|
+
if (this.tickTimer !== null) return;
|
|
8286
|
+
this.tickTimer = setInterval(() => void this.tick(), this.tickIntervalMs);
|
|
8287
|
+
}
|
|
8288
|
+
async stop() {
|
|
8289
|
+
this.draining = true;
|
|
8290
|
+
if (this.tickTimer !== null) {
|
|
8291
|
+
clearInterval(this.tickTimer);
|
|
8292
|
+
this.tickTimer = null;
|
|
8293
|
+
}
|
|
8294
|
+
const deadline = Date.now() + this.drainTimeoutMs;
|
|
8295
|
+
while (Date.now() < deadline) {
|
|
8296
|
+
const total = [...this.inFlight.values()].reduce((a, b) => a + b, 0);
|
|
8297
|
+
if (total === 0) break;
|
|
8298
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
8299
|
+
}
|
|
8300
|
+
if (this.inFlightAborts.size > 0) {
|
|
8301
|
+
for (const ctrl of this.inFlightAborts.values()) ctrl.abort();
|
|
8302
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
8305
|
+
async tick() {
|
|
8306
|
+
if (this.draining) return;
|
|
8307
|
+
const pending = this.queue.claim(Date.now());
|
|
8308
|
+
for (const row of pending) {
|
|
8309
|
+
const inFlight = this.inFlight.get(row.subscriptionId) ?? 0;
|
|
8310
|
+
if (inFlight >= this.maxConcurrentPerSub) continue;
|
|
8311
|
+
this.inFlight.set(row.subscriptionId, inFlight + 1);
|
|
8312
|
+
void this.executeDelivery(row);
|
|
8313
|
+
}
|
|
8314
|
+
}
|
|
8315
|
+
async executeDelivery(row) {
|
|
8316
|
+
const ctrl = new AbortController();
|
|
8317
|
+
this.inFlightAborts.set(row.id, ctrl);
|
|
8318
|
+
try {
|
|
8319
|
+
const subs = await this.store.list();
|
|
8320
|
+
const sub = subs.find((s) => s.id === row.subscriptionId);
|
|
8321
|
+
if (!sub) {
|
|
8322
|
+
this.queue.markFailed(row.id, MAX_ATTEMPTS, Date.now(), "subscription deleted");
|
|
8323
|
+
return;
|
|
8324
|
+
}
|
|
8325
|
+
let hostname;
|
|
8326
|
+
try {
|
|
8327
|
+
hostname = new URL(sub.url).hostname;
|
|
8328
|
+
} catch {
|
|
8329
|
+
this.queue.markFailed(row.id, MAX_ATTEMPTS, Date.now(), "invalid URL");
|
|
8330
|
+
return;
|
|
8331
|
+
}
|
|
8332
|
+
if (!this.allowPrivateHosts && isPrivateHost(hostname)) {
|
|
8333
|
+
this.queue.markFailed(
|
|
8334
|
+
row.id,
|
|
8335
|
+
MAX_ATTEMPTS,
|
|
8336
|
+
Date.now(),
|
|
8337
|
+
"URL resolves to private/loopback host"
|
|
8338
|
+
);
|
|
8339
|
+
return;
|
|
8340
|
+
}
|
|
8341
|
+
const signature = sign(sub.secret, row.payload);
|
|
8342
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
8343
|
+
let ok = false;
|
|
8344
|
+
let lastError = "";
|
|
8345
|
+
let aborted = false;
|
|
8346
|
+
try {
|
|
8347
|
+
const res = await this.fetchImpl(sub.url, {
|
|
8348
|
+
method: "POST",
|
|
8349
|
+
headers: {
|
|
8350
|
+
"Content-Type": "application/json",
|
|
8351
|
+
"X-Harness-Delivery-Id": row.id,
|
|
8352
|
+
"X-Harness-Event-Type": row.eventType,
|
|
8353
|
+
"X-Harness-Signature": signature,
|
|
8354
|
+
"X-Harness-Timestamp": String(Date.now())
|
|
8355
|
+
},
|
|
8356
|
+
body: row.payload,
|
|
8357
|
+
signal: ctrl.signal
|
|
8358
|
+
});
|
|
8359
|
+
ok = res.ok;
|
|
8360
|
+
if (!ok) lastError = `HTTP ${res.status}`;
|
|
8361
|
+
} catch (err) {
|
|
8362
|
+
aborted = ctrl.signal.aborted;
|
|
8363
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
8364
|
+
} finally {
|
|
8365
|
+
clearTimeout(timer);
|
|
8366
|
+
}
|
|
8367
|
+
if (aborted && this.draining) {
|
|
8368
|
+
return;
|
|
8369
|
+
}
|
|
8370
|
+
if (ok) {
|
|
8371
|
+
this.queue.markDelivered(row.id, Date.now());
|
|
8372
|
+
} else {
|
|
8373
|
+
const nextAttempt = row.attempt + 1;
|
|
8374
|
+
const delay = RETRY_DELAYS_MS[row.attempt] ?? 256e3;
|
|
8375
|
+
this.queue.markFailed(row.id, nextAttempt, Date.now() + delay, lastError);
|
|
8376
|
+
}
|
|
8377
|
+
} finally {
|
|
8378
|
+
this.inFlightAborts.delete(row.id);
|
|
8379
|
+
const cur = this.inFlight.get(row.subscriptionId) ?? 1;
|
|
8380
|
+
this.inFlight.set(row.subscriptionId, Math.max(0, cur - 1));
|
|
8381
|
+
}
|
|
8382
|
+
}
|
|
8383
|
+
};
|
|
8384
|
+
|
|
8385
|
+
// src/gateway/webhooks/events.ts
|
|
8386
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
8387
|
+
var WEBHOOK_TOPICS = [
|
|
8388
|
+
"interaction.created",
|
|
8389
|
+
"interaction.resolved",
|
|
8390
|
+
"maintenance:started",
|
|
8391
|
+
"maintenance:completed",
|
|
8392
|
+
"maintenance:error",
|
|
8393
|
+
"webhook.subscription.created",
|
|
8394
|
+
"webhook.subscription.deleted"
|
|
8395
|
+
];
|
|
8396
|
+
function newEventId2() {
|
|
8397
|
+
return `evt_${randomBytes6(8).toString("hex")}`;
|
|
8398
|
+
}
|
|
8399
|
+
function wireWebhookFanout({ bus, store, delivery }) {
|
|
8400
|
+
const handlers = [];
|
|
8401
|
+
for (const topic of WEBHOOK_TOPICS) {
|
|
8402
|
+
const eventType = topic.replace(":", ".");
|
|
8403
|
+
const fn = (data) => {
|
|
8404
|
+
void (async () => {
|
|
8405
|
+
const subs = await store.listForEvent(eventType);
|
|
8406
|
+
if (subs.length === 0) return;
|
|
8407
|
+
const event = {
|
|
8408
|
+
id: newEventId2(),
|
|
8409
|
+
type: eventType,
|
|
8410
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8411
|
+
data
|
|
8412
|
+
};
|
|
8413
|
+
for (const sub of subs) {
|
|
8414
|
+
delivery.enqueue(sub, event);
|
|
8415
|
+
}
|
|
8416
|
+
})();
|
|
8417
|
+
};
|
|
8418
|
+
bus.on(topic, fn);
|
|
8419
|
+
handlers.push({ topic, fn });
|
|
8420
|
+
}
|
|
8421
|
+
return () => {
|
|
8422
|
+
for (const { topic, fn } of handlers) bus.removeListener(topic, fn);
|
|
8423
|
+
};
|
|
8424
|
+
}
|
|
8425
|
+
|
|
8426
|
+
// src/gateway/telemetry/fanout.ts
|
|
8427
|
+
import { randomBytes as randomBytes7 } from "crypto";
|
|
8428
|
+
import { SpanKind } from "@harness-engineering/core";
|
|
8429
|
+
var TOPICS = {
|
|
8430
|
+
MAINTENANCE_STARTED: "maintenance:started",
|
|
8431
|
+
MAINTENANCE_COMPLETED: "maintenance:completed",
|
|
8432
|
+
MAINTENANCE_ERROR: "maintenance:error",
|
|
8433
|
+
DISPATCH_DECISION: "dispatch:decision",
|
|
8434
|
+
SKILL_INVOCATION: "skill_invocation"
|
|
8435
|
+
};
|
|
8436
|
+
var TELEMETRY_TYPE = {
|
|
8437
|
+
[TOPICS.MAINTENANCE_STARTED]: "telemetry.maintenance_run",
|
|
8438
|
+
[TOPICS.MAINTENANCE_COMPLETED]: "telemetry.maintenance_run",
|
|
8439
|
+
[TOPICS.MAINTENANCE_ERROR]: "telemetry.maintenance_run",
|
|
8440
|
+
[TOPICS.DISPATCH_DECISION]: "telemetry.dispatch_decision",
|
|
8441
|
+
[TOPICS.SKILL_INVOCATION]: "telemetry.skill_invocation"
|
|
8442
|
+
};
|
|
8443
|
+
var SPAN_NAME = {
|
|
8444
|
+
[TOPICS.MAINTENANCE_STARTED]: "maintenance_run",
|
|
8445
|
+
[TOPICS.MAINTENANCE_COMPLETED]: "maintenance_run",
|
|
8446
|
+
[TOPICS.MAINTENANCE_ERROR]: "maintenance_run",
|
|
8447
|
+
[TOPICS.DISPATCH_DECISION]: "dispatch_decision",
|
|
8448
|
+
[TOPICS.SKILL_INVOCATION]: "skill_invocation"
|
|
8449
|
+
};
|
|
8450
|
+
function newEventId3() {
|
|
8451
|
+
return `evt_${randomBytes7(8).toString("hex")}`;
|
|
8452
|
+
}
|
|
8453
|
+
function newTraceId() {
|
|
8454
|
+
return randomBytes7(16).toString("hex");
|
|
8455
|
+
}
|
|
8456
|
+
function newSpanId() {
|
|
8457
|
+
return randomBytes7(8).toString("hex");
|
|
8458
|
+
}
|
|
8459
|
+
function nowNs() {
|
|
8460
|
+
return BigInt(Date.now()) * 1000000n;
|
|
8461
|
+
}
|
|
8462
|
+
var MAX_ACTIVE_RUNS = 256;
|
|
8463
|
+
var ActiveRunRegistry = class {
|
|
8464
|
+
byKey = /* @__PURE__ */ new Map();
|
|
8465
|
+
open(key, ids) {
|
|
8466
|
+
if (this.byKey.has(key)) {
|
|
8467
|
+
this.byKey.set(key, ids);
|
|
8468
|
+
return;
|
|
8469
|
+
}
|
|
8470
|
+
if (this.byKey.size >= MAX_ACTIVE_RUNS) {
|
|
8471
|
+
const oldest = this.byKey.keys().next().value;
|
|
8472
|
+
if (oldest !== void 0) this.byKey.delete(oldest);
|
|
8473
|
+
}
|
|
8474
|
+
this.byKey.set(key, ids);
|
|
8475
|
+
}
|
|
8476
|
+
/**
|
|
8477
|
+
* Look up an active run; tries `correlationId`, then `taskId`. Returns
|
|
8478
|
+
* `undefined` when neither matches — the caller should treat the event as
|
|
8479
|
+
* a root span rather than guessing a parent.
|
|
8480
|
+
*/
|
|
8481
|
+
resolve(args) {
|
|
8482
|
+
if (args.correlationId && this.byKey.has(args.correlationId)) {
|
|
8483
|
+
return this.byKey.get(args.correlationId);
|
|
8484
|
+
}
|
|
8485
|
+
if (args.taskId && this.byKey.has(args.taskId)) {
|
|
8486
|
+
return this.byKey.get(args.taskId);
|
|
8487
|
+
}
|
|
8488
|
+
return void 0;
|
|
8489
|
+
}
|
|
8490
|
+
close(key) {
|
|
8491
|
+
this.byKey.delete(key);
|
|
8492
|
+
}
|
|
8493
|
+
/** Number of currently tracked runs. Exposed for tests + diagnostics. */
|
|
8494
|
+
get size() {
|
|
8495
|
+
return this.byKey.size;
|
|
8496
|
+
}
|
|
8497
|
+
};
|
|
8498
|
+
function buildAttributes(payload, extras = {}) {
|
|
8499
|
+
const attrs = { ...extras };
|
|
8500
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
8501
|
+
if (v === null || v === void 0) continue;
|
|
8502
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
8503
|
+
attrs[k] = v;
|
|
8504
|
+
}
|
|
8505
|
+
}
|
|
8506
|
+
return attrs;
|
|
8507
|
+
}
|
|
8508
|
+
function wireTelemetryFanout(params) {
|
|
8509
|
+
const { bus, exporter, webhookDelivery, store } = params;
|
|
8510
|
+
const registry = new ActiveRunRegistry();
|
|
8511
|
+
const handlers = [];
|
|
8512
|
+
const enqueueToMatchingSubs = async (event) => {
|
|
8513
|
+
const subs = await store.listForEvent(event.type);
|
|
8514
|
+
if (subs.length === 0) return;
|
|
8515
|
+
const filtered = subs.filter((sub) => sub.events.some((p) => eventMatches(p, event.type)));
|
|
8516
|
+
for (const sub of filtered) {
|
|
8517
|
+
webhookDelivery.enqueue(sub, event);
|
|
8518
|
+
}
|
|
8519
|
+
};
|
|
8520
|
+
const makeHandler = (topic) => (data) => {
|
|
8521
|
+
const payload = data ?? {};
|
|
8522
|
+
const correlationId = typeof payload["correlationId"] === "string" ? payload["correlationId"] : void 0;
|
|
8523
|
+
const taskId = typeof payload["taskId"] === "string" ? payload["taskId"] : void 0;
|
|
8524
|
+
let traceId;
|
|
8525
|
+
let spanId;
|
|
8526
|
+
let parentSpanId;
|
|
8527
|
+
let statusCode;
|
|
8528
|
+
if (topic === TOPICS.MAINTENANCE_STARTED) {
|
|
8529
|
+
traceId = newTraceId();
|
|
8530
|
+
spanId = newSpanId();
|
|
8531
|
+
const key = correlationId ?? taskId ?? `run_${spanId}`;
|
|
8532
|
+
registry.open(key, { traceId, spanId });
|
|
8533
|
+
} else if (topic === TOPICS.MAINTENANCE_COMPLETED || topic === TOPICS.MAINTENANCE_ERROR) {
|
|
8534
|
+
const existing = registry.resolve({
|
|
8535
|
+
...correlationId !== void 0 ? { correlationId } : {},
|
|
8536
|
+
...taskId !== void 0 ? { taskId } : {}
|
|
8537
|
+
});
|
|
8538
|
+
if (existing !== void 0) {
|
|
8539
|
+
traceId = existing.traceId;
|
|
8540
|
+
spanId = existing.spanId;
|
|
8541
|
+
} else {
|
|
8542
|
+
traceId = newTraceId();
|
|
8543
|
+
spanId = newSpanId();
|
|
8544
|
+
}
|
|
8545
|
+
statusCode = topic === TOPICS.MAINTENANCE_ERROR ? 2 : 1;
|
|
8546
|
+
const key = correlationId ?? taskId ?? "";
|
|
8547
|
+
if (key) registry.close(key);
|
|
8548
|
+
} else {
|
|
8549
|
+
const parent = registry.resolve({
|
|
8550
|
+
...correlationId !== void 0 ? { correlationId } : {},
|
|
8551
|
+
...taskId !== void 0 ? { taskId } : {}
|
|
8552
|
+
});
|
|
8553
|
+
traceId = parent?.traceId ?? newTraceId();
|
|
8554
|
+
spanId = newSpanId();
|
|
8555
|
+
parentSpanId = parent?.spanId;
|
|
8556
|
+
}
|
|
8557
|
+
const startNs = nowNs();
|
|
8558
|
+
const span = {
|
|
8559
|
+
traceId,
|
|
8560
|
+
spanId,
|
|
8561
|
+
...parentSpanId !== void 0 ? { parentSpanId } : {},
|
|
8562
|
+
name: SPAN_NAME[topic],
|
|
8563
|
+
kind: SpanKind.INTERNAL,
|
|
8564
|
+
startTimeNs: startNs,
|
|
8565
|
+
endTimeNs: startNs,
|
|
8566
|
+
attributes: buildAttributes(payload, { "harness.topic": topic }),
|
|
8567
|
+
...statusCode !== void 0 ? { statusCode } : {}
|
|
8568
|
+
};
|
|
8569
|
+
exporter.push(span);
|
|
8570
|
+
const gatewayEvent = {
|
|
8571
|
+
id: newEventId3(),
|
|
8572
|
+
type: TELEMETRY_TYPE[topic],
|
|
8573
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8574
|
+
data: payload,
|
|
8575
|
+
...correlationId !== void 0 ? { correlationId } : {}
|
|
8576
|
+
};
|
|
8577
|
+
void enqueueToMatchingSubs(gatewayEvent);
|
|
8578
|
+
};
|
|
8579
|
+
for (const topic of Object.values(TOPICS)) {
|
|
8580
|
+
const fn = makeHandler(topic);
|
|
8581
|
+
bus.on(topic, fn);
|
|
8582
|
+
handlers.push({ topic, fn });
|
|
8583
|
+
}
|
|
8584
|
+
return () => {
|
|
8585
|
+
for (const { topic, fn } of handlers) bus.removeListener(topic, fn);
|
|
8586
|
+
};
|
|
8587
|
+
}
|
|
8588
|
+
|
|
8589
|
+
// src/orchestrator.ts
|
|
8590
|
+
import { CacheMetricsRecorder, OTLPExporter } from "@harness-engineering/core";
|
|
8591
|
+
|
|
7107
8592
|
// src/logging/logger.ts
|
|
7108
8593
|
var StructuredLogger = class {
|
|
7109
8594
|
debug(message, context) {
|
|
@@ -7667,17 +9152,17 @@ var SingleProcessLeaderElector = class {
|
|
|
7667
9152
|
// src/maintenance/reporter.ts
|
|
7668
9153
|
import * as fs14 from "fs";
|
|
7669
9154
|
import * as path15 from "path";
|
|
7670
|
-
import { z as
|
|
7671
|
-
var RunResultSchema =
|
|
7672
|
-
taskId:
|
|
7673
|
-
startedAt:
|
|
7674
|
-
completedAt:
|
|
7675
|
-
status:
|
|
7676
|
-
findings:
|
|
7677
|
-
fixed:
|
|
7678
|
-
prUrl:
|
|
7679
|
-
prUpdated:
|
|
7680
|
-
error:
|
|
9155
|
+
import { z as z15 } from "zod";
|
|
9156
|
+
var RunResultSchema = z15.object({
|
|
9157
|
+
taskId: z15.string(),
|
|
9158
|
+
startedAt: z15.string(),
|
|
9159
|
+
completedAt: z15.string(),
|
|
9160
|
+
status: z15.enum(["success", "failure", "skipped", "no-issues"]),
|
|
9161
|
+
findings: z15.number(),
|
|
9162
|
+
fixed: z15.number(),
|
|
9163
|
+
prUrl: z15.string().nullable(),
|
|
9164
|
+
prUpdated: z15.boolean(),
|
|
9165
|
+
error: z15.string().optional()
|
|
7681
9166
|
});
|
|
7682
9167
|
var MAX_HISTORY = 500;
|
|
7683
9168
|
var fallbackLogger = {
|
|
@@ -7704,7 +9189,7 @@ var MaintenanceReporter = class {
|
|
|
7704
9189
|
await fs14.promises.mkdir(this.persistDir, { recursive: true });
|
|
7705
9190
|
const filePath = path15.join(this.persistDir, "history.json");
|
|
7706
9191
|
const data = await fs14.promises.readFile(filePath, "utf-8");
|
|
7707
|
-
const parsed =
|
|
9192
|
+
const parsed = z15.array(RunResultSchema).safeParse(JSON.parse(data));
|
|
7708
9193
|
if (parsed.success) {
|
|
7709
9194
|
this.history = parsed.data.slice(0, MAX_HISTORY);
|
|
7710
9195
|
}
|
|
@@ -8121,6 +9606,25 @@ var Orchestrator = class extends EventEmitter {
|
|
|
8121
9606
|
prDetector;
|
|
8122
9607
|
maintenanceScheduler = null;
|
|
8123
9608
|
maintenanceReporter = null;
|
|
9609
|
+
// Phase 3 webhooks. `webhookStore` is constructed at server-start and held
|
|
9610
|
+
// only as a local; it's passed into `ServerDependencies` and
|
|
9611
|
+
// `wireWebhookFanout` once and never re-read on `this`. The fan-out
|
|
9612
|
+
// teardown handle is kept on the instance so `stop()` can detach listeners.
|
|
9613
|
+
//
|
|
9614
|
+
// Phase 4 delivery durability: the WebhookQueue (SQLite at
|
|
9615
|
+
// `.harness/webhook-queue.sqlite`) and the WebhookDelivery worker are
|
|
9616
|
+
// retained as instance fields so `stop()` can drain in-flight deliveries
|
|
9617
|
+
// (await worker.stop()) and close the SQLite handle (queue.close()).
|
|
9618
|
+
webhookFanoutOff;
|
|
9619
|
+
webhookQueue;
|
|
9620
|
+
webhookDeliveryWorker;
|
|
9621
|
+
// Phase 5: prompt-cache metrics + OTLP trace export. Both are constructed
|
|
9622
|
+
// unconditionally so non-telemetry call sites can reference them safely; the
|
|
9623
|
+
// OTLPExporter is only handed a fanout subscription when config supplies an
|
|
9624
|
+
// endpoint, and `enabled: false` keeps push() a constant-time no-op.
|
|
9625
|
+
cacheMetrics;
|
|
9626
|
+
otlpExporter;
|
|
9627
|
+
telemetryFanoutOff;
|
|
8124
9628
|
orchestratorIdPromise;
|
|
8125
9629
|
recorder;
|
|
8126
9630
|
intelligenceRunner;
|
|
@@ -8155,6 +9659,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
8155
9659
|
*/
|
|
8156
9660
|
constructor(config, promptTemplate, overrides) {
|
|
8157
9661
|
super();
|
|
9662
|
+
this.setMaxListeners(50);
|
|
8158
9663
|
this.config = config;
|
|
8159
9664
|
this.promptTemplate = promptTemplate;
|
|
8160
9665
|
this.state = createEmptyState(config);
|
|
@@ -8181,7 +9686,8 @@ var Orchestrator = class extends EventEmitter {
|
|
|
8181
9686
|
this.renderer = new PromptRenderer();
|
|
8182
9687
|
this.overrideBackend = overrides?.backend ?? null;
|
|
8183
9688
|
this.interactionQueue = new InteractionQueue(
|
|
8184
|
-
path16.join(config.workspace.root, "..", "interactions")
|
|
9689
|
+
path16.join(config.workspace.root, "..", "interactions"),
|
|
9690
|
+
this
|
|
8185
9691
|
);
|
|
8186
9692
|
this.analysisArchive = new AnalysisArchive(path16.join(config.workspace.root, "..", "analyses"));
|
|
8187
9693
|
const backendsMap = this.config.agent.backends ?? {};
|
|
@@ -8197,6 +9703,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
8197
9703
|
this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
|
|
8198
9704
|
}
|
|
8199
9705
|
}
|
|
9706
|
+
this.cacheMetrics = new CacheMetricsRecorder();
|
|
8200
9707
|
if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
|
|
8201
9708
|
const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
|
|
8202
9709
|
const firstBackendName = Object.keys(this.config.agent.backends)[0];
|
|
@@ -8209,6 +9716,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
8209
9716
|
sandboxPolicy,
|
|
8210
9717
|
...this.config.agent.container !== void 0 ? { container: this.config.agent.container } : {},
|
|
8211
9718
|
...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
|
|
9719
|
+
cacheMetrics: this.cacheMetrics,
|
|
8212
9720
|
getResolverModelFor: (name) => {
|
|
8213
9721
|
const resolver = this.localResolvers.get(name);
|
|
8214
9722
|
return resolver ? () => resolver.resolveModel() : void 0;
|
|
@@ -8255,8 +9763,47 @@ var Orchestrator = class extends EventEmitter {
|
|
|
8255
9763
|
this.intelligenceRunner = new IntelligencePipelineRunner(ctx);
|
|
8256
9764
|
this.completionHandler = new CompletionHandler(ctx, this.postLifecycleComment.bind(this));
|
|
8257
9765
|
if (config.server?.port) {
|
|
9766
|
+
const webhookStore = new WebhookStore(
|
|
9767
|
+
path16.join(this.projectRoot, ".harness", "webhooks.json")
|
|
9768
|
+
);
|
|
9769
|
+
this.webhookQueue = new WebhookQueue(
|
|
9770
|
+
path16.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
|
|
9771
|
+
);
|
|
9772
|
+
const webhookDelivery = new WebhookDelivery({
|
|
9773
|
+
queue: this.webhookQueue,
|
|
9774
|
+
store: webhookStore
|
|
9775
|
+
});
|
|
9776
|
+
this.webhookDeliveryWorker = webhookDelivery;
|
|
9777
|
+
this.webhookFanoutOff = wireWebhookFanout({
|
|
9778
|
+
bus: this,
|
|
9779
|
+
store: webhookStore,
|
|
9780
|
+
delivery: webhookDelivery
|
|
9781
|
+
});
|
|
9782
|
+
webhookDelivery.start();
|
|
9783
|
+
const otlpCfg = config.telemetry?.export?.otlp;
|
|
9784
|
+
if (otlpCfg) {
|
|
9785
|
+
this.otlpExporter = new OTLPExporter({
|
|
9786
|
+
endpoint: otlpCfg.endpoint,
|
|
9787
|
+
...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
|
|
9788
|
+
...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
|
|
9789
|
+
...otlpCfg.flushIntervalMs !== void 0 ? { flushIntervalMs: otlpCfg.flushIntervalMs } : {},
|
|
9790
|
+
...otlpCfg.batchSize !== void 0 ? { batchSize: otlpCfg.batchSize } : {}
|
|
9791
|
+
});
|
|
9792
|
+
this.telemetryFanoutOff = wireTelemetryFanout({
|
|
9793
|
+
bus: this,
|
|
9794
|
+
exporter: this.otlpExporter,
|
|
9795
|
+
webhookDelivery,
|
|
9796
|
+
store: webhookStore
|
|
9797
|
+
});
|
|
9798
|
+
}
|
|
8258
9799
|
this.server = new OrchestratorServer(this, config.server.port, {
|
|
8259
9800
|
interactionQueue: this.interactionQueue,
|
|
9801
|
+
webhooks: {
|
|
9802
|
+
store: webhookStore,
|
|
9803
|
+
delivery: webhookDelivery,
|
|
9804
|
+
queue: this.webhookQueue
|
|
9805
|
+
},
|
|
9806
|
+
cacheMetrics: this.cacheMetrics,
|
|
8260
9807
|
plansDir: path16.resolve(config.workspace.root, "..", "docs", "plans"),
|
|
8261
9808
|
pipeline: this.pipeline,
|
|
8262
9809
|
analysisArchive: this.analysisArchive,
|
|
@@ -9165,6 +10712,9 @@ var Orchestrator = class extends EventEmitter {
|
|
|
9165
10712
|
if (this.server) {
|
|
9166
10713
|
void this.server.start();
|
|
9167
10714
|
}
|
|
10715
|
+
if (this.otlpExporter) {
|
|
10716
|
+
this.otlpExporter.start();
|
|
10717
|
+
}
|
|
9168
10718
|
await this.initLocalModelAndPipeline();
|
|
9169
10719
|
await this.ensureClaimManager();
|
|
9170
10720
|
const runningIssueIds = new Set(this.state.running.keys());
|
|
@@ -9228,6 +10778,26 @@ var Orchestrator = class extends EventEmitter {
|
|
|
9228
10778
|
this.maintenanceScheduler.stop();
|
|
9229
10779
|
this.maintenanceScheduler = null;
|
|
9230
10780
|
}
|
|
10781
|
+
if (this.webhookFanoutOff) {
|
|
10782
|
+
this.webhookFanoutOff();
|
|
10783
|
+
delete this.webhookFanoutOff;
|
|
10784
|
+
}
|
|
10785
|
+
if (this.telemetryFanoutOff) {
|
|
10786
|
+
this.telemetryFanoutOff();
|
|
10787
|
+
delete this.telemetryFanoutOff;
|
|
10788
|
+
}
|
|
10789
|
+
if (this.otlpExporter) {
|
|
10790
|
+
await this.otlpExporter.stop();
|
|
10791
|
+
delete this.otlpExporter;
|
|
10792
|
+
}
|
|
10793
|
+
if (this.webhookDeliveryWorker) {
|
|
10794
|
+
await this.webhookDeliveryWorker.stop();
|
|
10795
|
+
delete this.webhookDeliveryWorker;
|
|
10796
|
+
}
|
|
10797
|
+
if (this.webhookQueue) {
|
|
10798
|
+
this.webhookQueue.close();
|
|
10799
|
+
delete this.webhookQueue;
|
|
10800
|
+
}
|
|
9231
10801
|
if (this.server) {
|
|
9232
10802
|
this.server.stop();
|
|
9233
10803
|
}
|
|
@@ -9648,14 +11218,18 @@ export {
|
|
|
9648
11218
|
ClaimManager,
|
|
9649
11219
|
InteractionQueue,
|
|
9650
11220
|
LinearGraphQLStub,
|
|
11221
|
+
MAX_ATTEMPTS,
|
|
9651
11222
|
MockBackend,
|
|
9652
11223
|
ORCHESTRATOR_IDENTITY_FILE,
|
|
9653
11224
|
Orchestrator,
|
|
9654
11225
|
OrchestratorBackendFactory,
|
|
9655
11226
|
PRDetector,
|
|
9656
11227
|
PromptRenderer,
|
|
11228
|
+
RETRY_DELAYS_MS,
|
|
9657
11229
|
RoadmapTrackerAdapter,
|
|
9658
11230
|
StreamRecorder,
|
|
11231
|
+
TokenStore,
|
|
11232
|
+
WebhookQueue,
|
|
9659
11233
|
WorkflowLoader,
|
|
9660
11234
|
WorkspaceHooks,
|
|
9661
11235
|
WorkspaceManager,
|