@harness-engineering/orchestrator 0.4.2 → 0.4.4

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.js CHANGED
@@ -35,14 +35,18 @@ __export(index_exports, {
35
35
  ClaimManager: () => ClaimManager,
36
36
  InteractionQueue: () => InteractionQueue,
37
37
  LinearGraphQLStub: () => LinearGraphQLStub,
38
+ MAX_ATTEMPTS: () => MAX_ATTEMPTS,
38
39
  MockBackend: () => MockBackend,
39
40
  ORCHESTRATOR_IDENTITY_FILE: () => ORCHESTRATOR_IDENTITY_FILE,
40
41
  Orchestrator: () => Orchestrator,
41
42
  OrchestratorBackendFactory: () => OrchestratorBackendFactory,
42
43
  PRDetector: () => PRDetector,
43
44
  PromptRenderer: () => PromptRenderer,
45
+ RETRY_DELAYS_MS: () => RETRY_DELAYS_MS,
44
46
  RoadmapTrackerAdapter: () => RoadmapTrackerAdapter,
45
47
  StreamRecorder: () => StreamRecorder,
48
+ TokenStore: () => TokenStore,
49
+ WebhookQueue: () => WebhookQueue,
46
50
  WorkflowLoader: () => WorkflowLoader,
47
51
  WorkspaceHooks: () => WorkspaceHooks,
48
52
  WorkspaceManager: () => WorkspaceManager,
@@ -338,11 +342,18 @@ var path = __toESM(require("path"));
338
342
  var InteractionQueue = class {
339
343
  dir;
340
344
  pushListeners = [];
345
+ emitter;
341
346
  /**
342
347
  * @param dir - Directory path for storing interaction JSON files
348
+ * @param emitter - Optional event bus that receives `interaction.created`
349
+ * and `interaction.resolved` events. When omitted, the queue behaves as
350
+ * it did pre-Phase-2 (no emission). Phase 2 Task 8 wires the
351
+ * orchestrator (itself an EventEmitter) in as the bus so the SSE
352
+ * handler (`GET /api/v1/events`) can fan these out to clients.
343
353
  */
344
- constructor(dir) {
354
+ constructor(dir, emitter) {
345
355
  this.dir = dir;
356
+ this.emitter = emitter ?? null;
346
357
  }
347
358
  /**
348
359
  * Register a listener that fires after each push.
@@ -370,6 +381,13 @@ var InteractionQueue = class {
370
381
  for (const listener of this.pushListeners) {
371
382
  listener(interaction);
372
383
  }
384
+ this.emitter?.emit("interaction.created", {
385
+ id: interaction.id,
386
+ issueId: interaction.issueId,
387
+ type: interaction.type,
388
+ status: interaction.status,
389
+ createdAt: interaction.createdAt
390
+ });
373
391
  }
374
392
  /**
375
393
  * List all interactions (regardless of status).
@@ -416,6 +434,13 @@ var InteractionQueue = class {
416
434
  const interaction = JSON.parse(raw);
417
435
  interaction.status = status;
418
436
  await fs.writeFile(filePath, JSON.stringify(interaction, null, 2), "utf-8");
437
+ if (status === "resolved") {
438
+ this.emitter?.emit("interaction.resolved", {
439
+ id,
440
+ status: "resolved",
441
+ resolvedAt: (/* @__PURE__ */ new Date()).toISOString()
442
+ });
443
+ }
419
444
  }
420
445
  };
421
446
 
@@ -2710,8 +2735,8 @@ var PromptRenderer = class {
2710
2735
  // src/orchestrator.ts
2711
2736
  var import_node_events = require("events");
2712
2737
  var path16 = __toESM(require("path"));
2713
- var import_node_crypto7 = require("crypto");
2714
- var import_core10 = require("@harness-engineering/core");
2738
+ var import_node_crypto15 = require("crypto");
2739
+ var import_core11 = require("@harness-engineering/core");
2715
2740
 
2716
2741
  // src/intelligence/pipeline-runner.ts
2717
2742
  var path7 = __toESM(require("path"));
@@ -3272,7 +3297,7 @@ var CompletionHandler = class {
3272
3297
  };
3273
3298
 
3274
3299
  // src/orchestrator.ts
3275
- var import_core11 = require("@harness-engineering/core");
3300
+ var import_core12 = require("@harness-engineering/core");
3276
3301
 
3277
3302
  // src/tracker/adapters/github-issues-issue-tracker.ts
3278
3303
  var import_types9 = require("@harness-engineering/types");
@@ -3966,6 +3991,13 @@ function extractUsage(usage) {
3966
3991
  cacheReadTokens: usage.cache_read_input_tokens ?? 0
3967
3992
  };
3968
3993
  }
3994
+ function recordCacheUsage(recorder, rawUsage) {
3995
+ if (!recorder || !rawUsage) return;
3996
+ const cacheRead = rawUsage.cache_read_input_tokens ?? 0;
3997
+ const cacheCreation = rawUsage.cache_creation_input_tokens ?? 0;
3998
+ if (cacheRead === 0 && cacheCreation === 0) return;
3999
+ recorder.record("anthropic", cacheRead > 0, cacheCreation, cacheRead);
4000
+ }
3969
4001
  function extractToolResultText(blockContent) {
3970
4002
  if (typeof blockContent === "string") return blockContent;
3971
4003
  if (!Array.isArray(blockContent)) return "";
@@ -4047,8 +4079,10 @@ function mapClaudeEvent(rawEvent, sessionId) {
4047
4079
  var ClaudeBackend = class {
4048
4080
  name = "claude";
4049
4081
  command;
4050
- constructor(command = "claude") {
4051
- this.command = command;
4082
+ cacheMetrics;
4083
+ constructor(command = "claude", options = {}) {
4084
+ this.command = options.command ?? command;
4085
+ if (options.cacheMetrics) this.cacheMetrics = options.cacheMetrics;
4052
4086
  }
4053
4087
  async startSession(params) {
4054
4088
  const session = {
@@ -4114,6 +4148,9 @@ var ClaudeBackend = class {
4114
4148
  totalTokens: 0
4115
4149
  }
4116
4150
  };
4151
+ recordCacheUsage(this.cacheMetrics, rawEvent.usage);
4152
+ } else if (rawEvent.type === "assistant" && rawEvent.message?.stop_reason !== null && rawEvent.message?.stop_reason !== void 0) {
4153
+ recordCacheUsage(this.cacheMetrics, rawEvent.message?.usage);
4117
4154
  }
4118
4155
  for (const mapped of mapClaudeEvent(rawEvent, session.sessionId)) {
4119
4156
  yield mapped;
@@ -4967,12 +5004,14 @@ function makeGetModel(model) {
4967
5004
  if (Array.isArray(model) && model.length > 0) return () => model[0] ?? null;
4968
5005
  return () => null;
4969
5006
  }
4970
- function createBackend(def) {
5007
+ function createBackend(def, options = {}) {
4971
5008
  switch (def.type) {
4972
5009
  case "mock":
4973
5010
  return new MockBackend();
4974
5011
  case "claude":
4975
- return new ClaudeBackend(def.command ?? "claude");
5012
+ return new ClaudeBackend(def.command ?? "claude", {
5013
+ ...options.cacheMetrics ? { cacheMetrics: options.cacheMetrics } : {}
5014
+ });
4976
5015
  case "anthropic":
4977
5016
  return new AnthropicBackend({
4978
5017
  model: def.model,
@@ -5408,11 +5447,12 @@ var OrchestratorBackendFactory = class {
5408
5447
  const def = this.router.resolveDefinition(useCase);
5409
5448
  const name = this.router.resolve(useCase);
5410
5449
  let backend;
5450
+ const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
5411
5451
  if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
5412
5452
  const getModel = this.opts.getResolverModelFor(name);
5413
- backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def);
5453
+ backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def, createOpts);
5414
5454
  } else {
5415
- backend = createBackend(def);
5455
+ backend = createBackend(def, createOpts);
5416
5456
  }
5417
5457
  if (this.opts.sandboxPolicy === "docker" && this.opts.container) {
5418
5458
  backend = this.wrapInContainer(backend);
@@ -5799,13 +5839,75 @@ function handleInteractionsRoute(req, res, queue) {
5799
5839
  return false;
5800
5840
  }
5801
5841
 
5802
- // src/server/routes/plans.ts
5842
+ // src/server/routes/v1/interactions-resolve.ts
5803
5843
  var import_zod4 = require("zod");
5844
+ var BodySchema = import_zod4.z.object({ answer: import_zod4.z.unknown().optional() });
5845
+ var RESOLVE_PATH_RE = /^\/api\/v1\/interactions\/([a-zA-Z0-9_-]+)\/resolve(?:\?.*)?$/;
5846
+ function sendJSON(res, status, body) {
5847
+ res.writeHead(status, { "Content-Type": "application/json" });
5848
+ res.end(JSON.stringify(body));
5849
+ }
5850
+ function handleV1InteractionsResolveRoute(req, res, queue) {
5851
+ if (req.method !== "POST") return false;
5852
+ const match = RESOLVE_PATH_RE.exec(req.url ?? "");
5853
+ if (!match || !match[1]) return false;
5854
+ if (!queue) {
5855
+ sendJSON(res, 503, { error: "Interaction queue not available" });
5856
+ return true;
5857
+ }
5858
+ const id = match[1];
5859
+ void (async () => {
5860
+ let raw;
5861
+ try {
5862
+ raw = await readBody(req);
5863
+ } catch {
5864
+ sendJSON(res, 413, { error: "Body too large" });
5865
+ return;
5866
+ }
5867
+ if (raw.length > 0) {
5868
+ try {
5869
+ const json = JSON.parse(raw);
5870
+ const parsed = BodySchema.safeParse(json);
5871
+ if (!parsed.success) {
5872
+ sendJSON(res, 400, { error: "Invalid body", issues: parsed.error.issues });
5873
+ return;
5874
+ }
5875
+ } catch {
5876
+ sendJSON(res, 400, { error: "Invalid JSON body" });
5877
+ return;
5878
+ }
5879
+ }
5880
+ try {
5881
+ const existing = (await queue.list()).find((i) => i.id === id);
5882
+ if (!existing) {
5883
+ sendJSON(res, 404, { error: `Interaction ${id} not found` });
5884
+ return;
5885
+ }
5886
+ if (existing.status === "resolved") {
5887
+ sendJSON(res, 409, { error: `Interaction ${id} already resolved` });
5888
+ return;
5889
+ }
5890
+ await queue.updateStatus(id, "resolved");
5891
+ sendJSON(res, 200, { resolved: true });
5892
+ } catch (err) {
5893
+ const msg = err instanceof Error ? err.message : "Failed to resolve";
5894
+ if (msg.includes("not found")) {
5895
+ sendJSON(res, 404, { error: msg });
5896
+ return;
5897
+ }
5898
+ sendJSON(res, 500, { error: "Internal error resolving interaction" });
5899
+ }
5900
+ })();
5901
+ return true;
5902
+ }
5903
+
5904
+ // src/server/routes/plans.ts
5905
+ var import_zod5 = require("zod");
5804
5906
  var fs9 = __toESM(require("fs/promises"));
5805
5907
  var path9 = __toESM(require("path"));
5806
- var PlanWriteSchema = import_zod4.z.object({
5807
- filename: import_zod4.z.string().min(1),
5808
- content: import_zod4.z.string().min(1)
5908
+ var PlanWriteSchema = import_zod5.z.object({
5909
+ filename: import_zod5.z.string().min(1),
5910
+ content: import_zod5.z.string().min(1)
5809
5911
  });
5810
5912
  function handlePlansRoute(req, res, plansDir) {
5811
5913
  const { method, url } = req;
@@ -5849,7 +5951,7 @@ function handlePlansRoute(req, res, plansDir) {
5849
5951
  var import_node_child_process8 = require("child_process");
5850
5952
  var import_node_crypto5 = require("crypto");
5851
5953
  var readline2 = __toESM(require("readline"));
5852
- var import_zod5 = require("zod");
5954
+ var import_zod6 = require("zod");
5853
5955
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5854
5956
  var SAFE_ENV_PREFIXES = [
5855
5957
  "PATH",
@@ -5884,10 +5986,10 @@ function buildChildEnv() {
5884
5986
  }
5885
5987
  return env;
5886
5988
  }
5887
- var ChatRequestSchema = import_zod5.z.object({
5888
- prompt: import_zod5.z.string().min(1),
5889
- system: import_zod5.z.string().optional(),
5890
- sessionId: import_zod5.z.string().regex(UUID_RE).optional()
5989
+ var ChatRequestSchema = import_zod6.z.object({
5990
+ prompt: import_zod6.z.string().min(1),
5991
+ system: import_zod6.z.string().optional(),
5992
+ sessionId: import_zod6.z.string().regex(UUID_RE).optional()
5891
5993
  });
5892
5994
  function handleChatProxyRoute(req, res, command = "claude") {
5893
5995
  const { method, url } = req;
@@ -6097,11 +6199,11 @@ function extractChunks(event) {
6097
6199
 
6098
6200
  // src/server/routes/analyze.ts
6099
6201
  var import_intelligence4 = require("@harness-engineering/intelligence");
6100
- var import_zod6 = require("zod");
6101
- var AnalyzeRequestSchema = import_zod6.z.object({
6102
- title: import_zod6.z.string().min(1),
6103
- description: import_zod6.z.string().optional(),
6104
- labels: import_zod6.z.array(import_zod6.z.string()).optional()
6202
+ var import_zod7 = require("zod");
6203
+ var AnalyzeRequestSchema = import_zod7.z.object({
6204
+ title: import_zod7.z.string().min(1),
6205
+ description: import_zod7.z.string().optional(),
6206
+ labels: import_zod7.z.array(import_zod7.z.string()).optional()
6105
6207
  });
6106
6208
  function emit2(res, event) {
6107
6209
  res.write(`data: ${JSON.stringify(event)}
@@ -6210,21 +6312,21 @@ function handleAnalyzeRoute(req, res, pipeline) {
6210
6312
  var fs10 = __toESM(require("fs/promises"));
6211
6313
  var path10 = __toESM(require("path"));
6212
6314
  var import_core7 = require("@harness-engineering/core");
6213
- var import_zod7 = require("zod");
6214
- var AppendRoadmapRequestSchema = import_zod7.z.object({
6215
- title: import_zod7.z.string().min(1),
6216
- summary: import_zod7.z.string().optional(),
6217
- labels: import_zod7.z.array(import_zod7.z.string()).optional(),
6218
- enrichedSpec: import_zod7.z.object({
6219
- intent: import_zod7.z.string(),
6220
- unknowns: import_zod7.z.array(import_zod7.z.string()),
6221
- ambiguities: import_zod7.z.array(import_zod7.z.string()),
6222
- riskSignals: import_zod7.z.array(import_zod7.z.string()),
6223
- affectedSystems: import_zod7.z.array(import_zod7.z.object({ name: import_zod7.z.string() }))
6315
+ var import_zod8 = require("zod");
6316
+ var AppendRoadmapRequestSchema = import_zod8.z.object({
6317
+ title: import_zod8.z.string().min(1),
6318
+ summary: import_zod8.z.string().optional(),
6319
+ labels: import_zod8.z.array(import_zod8.z.string()).optional(),
6320
+ enrichedSpec: import_zod8.z.object({
6321
+ intent: import_zod8.z.string(),
6322
+ unknowns: import_zod8.z.array(import_zod8.z.string()),
6323
+ ambiguities: import_zod8.z.array(import_zod8.z.string()),
6324
+ riskSignals: import_zod8.z.array(import_zod8.z.string()),
6325
+ affectedSystems: import_zod8.z.array(import_zod8.z.object({ name: import_zod8.z.string() }))
6224
6326
  }).optional(),
6225
- cmlRecommendedRoute: import_zod7.z.enum(["local", "human", "simulation-required"]).optional()
6327
+ cmlRecommendedRoute: import_zod8.z.enum(["local", "human", "simulation-required"]).optional()
6226
6328
  });
6227
- function sendJSON(res, status, body) {
6329
+ function sendJSON2(res, status, body) {
6228
6330
  res.writeHead(status, { "Content-Type": "application/json" });
6229
6331
  res.end(JSON.stringify(body));
6230
6332
  }
@@ -6233,7 +6335,7 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6233
6335
  void (async () => {
6234
6336
  try {
6235
6337
  if (!roadmapPath) {
6236
- sendJSON(res, 503, { error: "Roadmap path not configured" });
6338
+ sendJSON2(res, 503, { error: "Roadmap path not configured" });
6237
6339
  return;
6238
6340
  }
6239
6341
  const projectRoot = path10.dirname(path10.dirname(roadmapPath));
@@ -6241,18 +6343,18 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6241
6343
  if (mode === "file-less") {
6242
6344
  const trackerCfg = (0, import_core7.loadTrackerClientConfigFromProject)(projectRoot);
6243
6345
  if (!trackerCfg.ok) {
6244
- sendJSON(res, 500, { error: trackerCfg.error.message });
6346
+ sendJSON2(res, 500, { error: trackerCfg.error.message });
6245
6347
  return;
6246
6348
  }
6247
6349
  const clientR = (0, import_core7.createTrackerClient)(trackerCfg.value);
6248
6350
  if (!clientR.ok) {
6249
- sendJSON(res, 500, { error: clientR.error.message });
6351
+ sendJSON2(res, 500, { error: clientR.error.message });
6250
6352
  return;
6251
6353
  }
6252
6354
  const body2 = await readBody(req);
6253
6355
  const parseResult = AppendRoadmapRequestSchema.safeParse(JSON.parse(body2));
6254
6356
  if (!parseResult.success) {
6255
- sendJSON(res, 400, {
6357
+ sendJSON2(res, 400, {
6256
6358
  error: parseResult.error.issues[0]?.message ?? "Invalid request body"
6257
6359
  });
6258
6360
  return;
@@ -6265,13 +6367,13 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6265
6367
  const r = await clientR.value.create(newFeature);
6266
6368
  if (!r.ok) {
6267
6369
  if (r.error instanceof import_core7.ConflictError) {
6268
- sendJSON(res, 409, (0, import_core7.makeTrackerConflictBody)(r.error));
6370
+ sendJSON2(res, 409, (0, import_core7.makeTrackerConflictBody)(r.error));
6269
6371
  return;
6270
6372
  }
6271
- sendJSON(res, 502, { error: r.error.message });
6373
+ sendJSON2(res, 502, { error: r.error.message });
6272
6374
  return;
6273
6375
  }
6274
- sendJSON(res, 201, {
6376
+ sendJSON2(res, 201, {
6275
6377
  ok: true,
6276
6378
  featureName: r.value.name,
6277
6379
  externalId: r.value.externalId
@@ -6281,18 +6383,18 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6281
6383
  const body = await readBody(req);
6282
6384
  const result = AppendRoadmapRequestSchema.safeParse(JSON.parse(body));
6283
6385
  if (!result.success) {
6284
- sendJSON(res, 400, { error: result.error.issues[0]?.message ?? "Invalid request body" });
6386
+ sendJSON2(res, 400, { error: result.error.issues[0]?.message ?? "Invalid request body" });
6285
6387
  return;
6286
6388
  }
6287
6389
  const parsed = result.data;
6288
6390
  if (parsed.title.includes("\n") || parsed.title.includes("###")) {
6289
- sendJSON(res, 400, { error: "Title must not contain newlines or markdown headings" });
6391
+ sendJSON2(res, 400, { error: "Title must not contain newlines or markdown headings" });
6290
6392
  return;
6291
6393
  }
6292
6394
  const content = await fs10.readFile(roadmapPath, "utf-8");
6293
6395
  const roadmapResult = (0, import_core7.parseRoadmap)(content);
6294
6396
  if (!roadmapResult.ok) {
6295
- sendJSON(res, 500, { error: "Failed to parse roadmap file" });
6397
+ sendJSON2(res, 500, { error: "Failed to parse roadmap file" });
6296
6398
  return;
6297
6399
  }
6298
6400
  const roadmap = roadmapResult.value;
@@ -6322,11 +6424,11 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6322
6424
  const serialized = (0, import_core7.serializeRoadmap)(roadmap);
6323
6425
  await fs10.writeFile(tmpPath, serialized, "utf-8");
6324
6426
  await fs10.rename(tmpPath, roadmapPath);
6325
- sendJSON(res, 201, { ok: true, featureName: parsed.title });
6427
+ sendJSON2(res, 201, { ok: true, featureName: parsed.title });
6326
6428
  } catch (err) {
6327
6429
  const msg = err instanceof Error ? err.message : "Failed to append to roadmap";
6328
6430
  if (!res.headersSent) {
6329
- sendJSON(res, 500, { error: msg });
6431
+ sendJSON2(res, 500, { error: msg });
6330
6432
  }
6331
6433
  }
6332
6434
  })();
@@ -6335,13 +6437,13 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6335
6437
 
6336
6438
  // src/server/routes/dispatch-actions.ts
6337
6439
  var import_node_crypto6 = require("crypto");
6338
- var import_zod8 = require("zod");
6339
- var DispatchAdHocRequestSchema = import_zod8.z.object({
6340
- title: import_zod8.z.string().min(1),
6341
- description: import_zod8.z.string().optional(),
6342
- labels: import_zod8.z.array(import_zod8.z.string()).optional()
6440
+ var import_zod9 = require("zod");
6441
+ var DispatchAdHocRequestSchema = import_zod9.z.object({
6442
+ title: import_zod9.z.string().min(1),
6443
+ description: import_zod9.z.string().optional(),
6444
+ labels: import_zod9.z.array(import_zod9.z.string()).optional()
6343
6445
  });
6344
- function sendJSON2(res, status, body) {
6446
+ function sendJSON3(res, status, body) {
6345
6447
  res.writeHead(status, { "Content-Type": "application/json" });
6346
6448
  res.end(JSON.stringify(body));
6347
6449
  }
@@ -6355,13 +6457,13 @@ function handleDispatchActionsRoute(req, res, dispatchFn) {
6355
6457
  void (async () => {
6356
6458
  try {
6357
6459
  if (!dispatchFn) {
6358
- sendJSON2(res, 503, { error: "Dispatch not available" });
6460
+ sendJSON3(res, 503, { error: "Dispatch not available" });
6359
6461
  return;
6360
6462
  }
6361
6463
  const body = await readBody(req);
6362
6464
  const result = DispatchAdHocRequestSchema.safeParse(JSON.parse(body));
6363
6465
  if (!result.success) {
6364
- sendJSON2(res, 400, { error: result.error.issues[0]?.message ?? "Invalid request body" });
6466
+ sendJSON3(res, 400, { error: result.error.issues[0]?.message ?? "Invalid request body" });
6365
6467
  return;
6366
6468
  }
6367
6469
  const parsed = result.data;
@@ -6384,11 +6486,11 @@ function handleDispatchActionsRoute(req, res, dispatchFn) {
6384
6486
  externalId: null
6385
6487
  };
6386
6488
  await dispatchFn(issue);
6387
- sendJSON2(res, 200, { ok: true, issueId: id });
6489
+ sendJSON3(res, 200, { ok: true, issueId: id });
6388
6490
  } catch (err) {
6389
6491
  const msg = err instanceof Error ? err.message : "Dispatch failed";
6390
6492
  if (!res.headersSent) {
6391
- sendJSON2(res, 500, { error: msg });
6493
+ sendJSON3(res, 500, { error: msg });
6392
6494
  }
6393
6495
  }
6394
6496
  })();
@@ -6436,7 +6538,7 @@ function handleAnalysesRoute(req, res, archive) {
6436
6538
  }
6437
6539
 
6438
6540
  // src/server/routes/maintenance.ts
6439
- var import_zod9 = require("zod");
6541
+ var import_zod10 = require("zod");
6440
6542
  function toMaintenanceHistoryEntry(r) {
6441
6543
  const durationMs = r.startedAt && r.completedAt ? Date.parse(r.completedAt) - Date.parse(r.startedAt) : 0;
6442
6544
  const status = r.status === "failure" ? "failed" : r.status;
@@ -6451,27 +6553,27 @@ function toMaintenanceHistoryEntry(r) {
6451
6553
  if (r.error !== void 0) entry.error = r.error;
6452
6554
  return entry;
6453
6555
  }
6454
- var TriggerRequestSchema = import_zod9.z.object({
6455
- taskId: import_zod9.z.string().min(1)
6556
+ var TriggerRequestSchema = import_zod10.z.object({
6557
+ taskId: import_zod10.z.string().min(1)
6456
6558
  });
6457
- function sendJSON3(res, status, body) {
6559
+ function sendJSON4(res, status, body) {
6458
6560
  res.writeHead(status, { "Content-Type": "application/json" });
6459
6561
  res.end(JSON.stringify(body));
6460
6562
  }
6461
6563
  function handleGetSchedule(res, deps) {
6462
6564
  const status = deps.scheduler.getStatus();
6463
- sendJSON3(res, 200, status.schedule);
6565
+ sendJSON4(res, 200, status.schedule);
6464
6566
  }
6465
6567
  function handleGetStatus(res, deps) {
6466
6568
  const status = deps.scheduler.getStatus();
6467
- sendJSON3(res, 200, status);
6569
+ sendJSON4(res, 200, status);
6468
6570
  }
6469
6571
  function handleGetHistory(res, deps, queryString) {
6470
6572
  const params = new URLSearchParams(queryString);
6471
6573
  const limit = Math.min(100, Math.max(1, parseInt(params.get("limit") ?? "20", 10) || 20));
6472
6574
  const offset = Math.max(0, parseInt(params.get("offset") ?? "0", 10) || 0);
6473
6575
  const history = deps.reporter.getHistory(limit, offset).map(toMaintenanceHistoryEntry);
6474
- sendJSON3(res, 200, history);
6576
+ sendJSON4(res, 200, history);
6475
6577
  }
6476
6578
  function handlePostTrigger(req, res, deps) {
6477
6579
  void (async () => {
@@ -6481,20 +6583,20 @@ function handlePostTrigger(req, res, deps) {
6481
6583
  try {
6482
6584
  json = JSON.parse(body);
6483
6585
  } catch {
6484
- sendJSON3(res, 400, { error: "Invalid JSON body" });
6586
+ sendJSON4(res, 400, { error: "Invalid JSON body" });
6485
6587
  return;
6486
6588
  }
6487
6589
  const result = TriggerRequestSchema.safeParse(json);
6488
6590
  if (!result.success) {
6489
- sendJSON3(res, 400, { error: "Missing taskId string" });
6591
+ sendJSON4(res, 400, { error: "Missing taskId string" });
6490
6592
  return;
6491
6593
  }
6492
6594
  await deps.triggerFn(result.data.taskId);
6493
- sendJSON3(res, 200, { ok: true, taskId: result.data.taskId });
6595
+ sendJSON4(res, 200, { ok: true, taskId: result.data.taskId });
6494
6596
  } catch (err) {
6495
6597
  const msg = err instanceof Error ? err.message : "Trigger failed";
6496
6598
  if (!res.headersSent) {
6497
- sendJSON3(res, 500, { error: msg });
6599
+ sendJSON4(res, 500, { error: msg });
6498
6600
  }
6499
6601
  }
6500
6602
  })();
@@ -6503,7 +6605,7 @@ function handleMaintenanceRoute(req, res, deps) {
6503
6605
  const { method, url } = req;
6504
6606
  if (!url?.startsWith("/api/maintenance/")) return false;
6505
6607
  if (!deps) {
6506
- sendJSON3(res, 503, { error: "Maintenance not available" });
6608
+ sendJSON4(res, 503, { error: "Maintenance not available" });
6507
6609
  return true;
6508
6610
  }
6509
6611
  const [pathname, queryString] = url.split("?");
@@ -6524,16 +6626,296 @@ function handleMaintenanceRoute(req, res, deps) {
6524
6626
  handlePostTrigger(req, res, deps);
6525
6627
  return true;
6526
6628
  }
6527
- sendJSON3(res, 404, { error: "Not found" });
6629
+ sendJSON4(res, 404, { error: "Not found" });
6630
+ return true;
6631
+ }
6632
+
6633
+ // src/server/routes/v1/jobs-maintenance.ts
6634
+ var import_node_crypto7 = require("crypto");
6635
+ var import_zod11 = require("zod");
6636
+ var BodySchema2 = import_zod11.z.object({
6637
+ taskId: import_zod11.z.string().min(1).max(200),
6638
+ params: import_zod11.z.record(import_zod11.z.unknown()).optional()
6639
+ });
6640
+ function sendJSON5(res, status, body) {
6641
+ res.writeHead(status, { "Content-Type": "application/json" });
6642
+ res.end(JSON.stringify(body));
6643
+ }
6644
+ function handleV1JobsMaintenanceRoute(req, res, deps) {
6645
+ if (req.url !== "/api/v1/jobs/maintenance" || req.method !== "POST") return false;
6646
+ if (!deps) {
6647
+ sendJSON5(res, 503, { error: "Maintenance not available" });
6648
+ return true;
6649
+ }
6650
+ void (async () => {
6651
+ let raw;
6652
+ try {
6653
+ raw = await readBody(req);
6654
+ } catch (err) {
6655
+ sendJSON5(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
6656
+ return;
6657
+ }
6658
+ let json;
6659
+ try {
6660
+ json = JSON.parse(raw);
6661
+ } catch {
6662
+ sendJSON5(res, 400, { error: "Invalid JSON body" });
6663
+ return;
6664
+ }
6665
+ const parsed = BodySchema2.safeParse(json);
6666
+ if (!parsed.success) {
6667
+ sendJSON5(res, 400, { error: "Invalid body", issues: parsed.error.issues });
6668
+ return;
6669
+ }
6670
+ const runId = `run_${(0, import_node_crypto7.randomBytes)(8).toString("hex")}`;
6671
+ try {
6672
+ await deps.triggerFn(parsed.data.taskId);
6673
+ sendJSON5(res, 200, { ok: true, taskId: parsed.data.taskId, runId });
6674
+ } catch (err) {
6675
+ const msg = err instanceof Error ? err.message : "Trigger failed";
6676
+ const lower = msg.toLowerCase();
6677
+ if (lower.includes("unknown task") || lower.includes("not found")) {
6678
+ sendJSON5(res, 404, { error: msg });
6679
+ return;
6680
+ }
6681
+ if (lower.includes("already running")) {
6682
+ sendJSON5(res, 409, { error: msg });
6683
+ return;
6684
+ }
6685
+ sendJSON5(res, 500, { error: "Internal error triggering maintenance task" });
6686
+ }
6687
+ })();
6688
+ return true;
6689
+ }
6690
+
6691
+ // src/server/routes/v1/events-sse.ts
6692
+ var import_node_crypto8 = require("crypto");
6693
+ var SSE_TOPICS = [
6694
+ "state_change",
6695
+ "agent_event",
6696
+ "interaction.created",
6697
+ "interaction.resolved",
6698
+ "maintenance:started",
6699
+ "maintenance:completed",
6700
+ "maintenance:error",
6701
+ "maintenance:baseref_fallback",
6702
+ "local-model:status",
6703
+ // ── Phase 3 ──
6704
+ "webhook.subscription.created",
6705
+ "webhook.subscription.deleted"
6706
+ ];
6707
+ var HEARTBEAT_MS = 15e3;
6708
+ function newEventId() {
6709
+ return `evt_${(0, import_node_crypto8.randomBytes)(8).toString("hex")}`;
6710
+ }
6711
+ function handleV1EventsSseRoute(req, res, bus) {
6712
+ if (req.method !== "GET" || req.url !== "/api/v1/events") return false;
6713
+ res.setHeader("Content-Type", "text/event-stream");
6714
+ res.setHeader("Cache-Control", "no-cache");
6715
+ res.setHeader("Connection", "keep-alive");
6716
+ res.setHeader("X-Accel-Buffering", "no");
6717
+ res.writeHead(200);
6718
+ res.write(`: harness gateway SSE \u2014 connected at ${(/* @__PURE__ */ new Date()).toISOString()}
6719
+
6720
+ `);
6721
+ const listeners = [];
6722
+ for (const topic of SSE_TOPICS) {
6723
+ const fn = (data) => {
6724
+ try {
6725
+ const frame = `event: ${topic}
6726
+ data: ${JSON.stringify(data)}
6727
+ id: ${newEventId()}
6728
+
6729
+ `;
6730
+ res.write(frame);
6731
+ } catch {
6732
+ }
6733
+ };
6734
+ bus.on(topic, fn);
6735
+ listeners.push({ topic, fn });
6736
+ }
6737
+ const heartbeat = setInterval(() => {
6738
+ try {
6739
+ res.write(": heartbeat\n\n");
6740
+ } catch {
6741
+ }
6742
+ }, HEARTBEAT_MS);
6743
+ heartbeat.unref();
6744
+ const cleanup = () => {
6745
+ clearInterval(heartbeat);
6746
+ for (const { topic, fn } of listeners) bus.removeListener(topic, fn);
6747
+ };
6748
+ res.on("close", cleanup);
6749
+ res.on("finish", cleanup);
6528
6750
  return true;
6529
6751
  }
6530
6752
 
6753
+ // src/server/routes/v1/webhooks.ts
6754
+ var import_zod12 = require("zod");
6755
+
6756
+ // src/server/utils/url-guard.ts
6757
+ var PRIVATE_HOSTNAME_RE = /^(localhost|.*\.local)$/i;
6758
+ 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)$/;
6759
+ var LOOPBACK_IPV6_RE = /^(::1|::ffff:127\.\d+\.\d+\.\d+|::ffff:0:127\.\d+\.\d+\.\d+)$/i;
6760
+ function isPrivateHost(hostname) {
6761
+ return PRIVATE_HOSTNAME_RE.test(hostname) || PRIVATE_IPV4_RE.test(hostname) || LOOPBACK_IPV6_RE.test(hostname);
6762
+ }
6763
+
6764
+ // src/server/routes/v1/webhooks.ts
6765
+ var import_types21 = require("@harness-engineering/types");
6766
+ function isAdminAuth(authContext) {
6767
+ if (!authContext) return false;
6768
+ if (authContext.scopes.includes("admin")) return true;
6769
+ if (authContext.id.startsWith("tok_legacy_env")) return true;
6770
+ return false;
6771
+ }
6772
+ function getAuthContext(req) {
6773
+ return req._authToken;
6774
+ }
6775
+ var CreateBody = import_zod12.z.object({
6776
+ url: import_zod12.z.string().url(),
6777
+ events: import_zod12.z.array(import_zod12.z.string().min(1)).min(1)
6778
+ });
6779
+ var QUEUE_STATS_PATH_RE = /^\/api\/v1\/webhooks\/queue\/stats(?:\?.*)?$/;
6780
+ var DELETE_PATH_RE = /^\/api\/v1\/webhooks\/([a-zA-Z0-9_-]+)(?:\?.*)?$/;
6781
+ var LIST_OR_CREATE_PATH_RE = /^\/api\/v1\/webhooks(?:\?.*)?$/;
6782
+ function sendJSON6(res, status, body) {
6783
+ res.writeHead(status, { "Content-Type": "application/json" });
6784
+ res.end(JSON.stringify(body));
6785
+ }
6786
+ var unauthDevWarnedThisProcess = false;
6787
+ function maybeWarnUnauthDev(tokenId, url) {
6788
+ if (unauthDevWarnedThisProcess) return;
6789
+ const isUnauthDev = tokenId === "tok_legacy_env" || process.env["HARNESS_UNAUTH_DEV_ACTIVE"] === "1";
6790
+ if (!isUnauthDev) return;
6791
+ unauthDevWarnedThisProcess = true;
6792
+ console.warn(
6793
+ `[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.`
6794
+ );
6795
+ }
6796
+ function handleV1WebhooksRoute(req, res, deps) {
6797
+ const url = req.url ?? "";
6798
+ const method = req.method ?? "GET";
6799
+ if (method === "GET" && QUEUE_STATS_PATH_RE.test(url)) {
6800
+ if (!deps.queue) {
6801
+ sendJSON6(res, 503, { error: "Queue not available" });
6802
+ return true;
6803
+ }
6804
+ sendJSON6(res, 200, deps.queue.stats());
6805
+ return true;
6806
+ }
6807
+ if (method === "GET" && LIST_OR_CREATE_PATH_RE.test(url)) {
6808
+ void (async () => {
6809
+ const subs = await deps.store.list();
6810
+ const authContext = getAuthContext(req);
6811
+ const visible = isAdminAuth(authContext) ? subs : subs.filter((s) => s.tokenId === authContext?.id);
6812
+ const publicView = visible.map((s) => import_types21.WebhookSubscriptionPublicSchema.parse(s));
6813
+ sendJSON6(res, 200, publicView);
6814
+ })();
6815
+ return true;
6816
+ }
6817
+ if (method === "POST" && LIST_OR_CREATE_PATH_RE.test(url)) {
6818
+ void (async () => {
6819
+ let raw;
6820
+ try {
6821
+ raw = await readBody(req);
6822
+ } catch (err) {
6823
+ sendJSON6(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
6824
+ return;
6825
+ }
6826
+ let json;
6827
+ try {
6828
+ json = JSON.parse(raw);
6829
+ } catch {
6830
+ sendJSON6(res, 400, { error: "Invalid JSON body" });
6831
+ return;
6832
+ }
6833
+ const parsed = CreateBody.safeParse(json);
6834
+ if (!parsed.success) {
6835
+ sendJSON6(res, 400, { error: "Invalid body", issues: parsed.error.issues });
6836
+ return;
6837
+ }
6838
+ if (!parsed.data.url.startsWith("https://")) {
6839
+ sendJSON6(res, 422, { error: "URL must use https" });
6840
+ return;
6841
+ }
6842
+ const targetHostname = new URL(parsed.data.url).hostname;
6843
+ if (isPrivateHost(targetHostname)) {
6844
+ sendJSON6(res, 422, { error: "URL must not target private or loopback addresses" });
6845
+ return;
6846
+ }
6847
+ const tokenId = getAuthContext(req)?.id ?? "unknown";
6848
+ const sub = await deps.store.create({
6849
+ tokenId,
6850
+ url: parsed.data.url,
6851
+ events: parsed.data.events
6852
+ });
6853
+ maybeWarnUnauthDev(tokenId, parsed.data.url);
6854
+ deps.bus.emit("webhook.subscription.created", {
6855
+ id: sub.id,
6856
+ tokenId: sub.tokenId,
6857
+ url: sub.url,
6858
+ events: sub.events,
6859
+ createdAt: sub.createdAt
6860
+ });
6861
+ sendJSON6(res, 200, sub);
6862
+ })();
6863
+ return true;
6864
+ }
6865
+ const m = method === "DELETE" ? DELETE_PATH_RE.exec(url) : null;
6866
+ if (m) {
6867
+ const id = m[1] ?? "";
6868
+ void (async () => {
6869
+ const authContext = getAuthContext(req);
6870
+ const subs = await deps.store.list();
6871
+ const sub = subs.find((s) => s.id === id);
6872
+ if (!sub) {
6873
+ sendJSON6(res, 404, { error: "Subscription not found" });
6874
+ return;
6875
+ }
6876
+ if (!isAdminAuth(authContext) && sub.tokenId !== authContext?.id) {
6877
+ sendJSON6(res, 403, { error: "forbidden" });
6878
+ return;
6879
+ }
6880
+ const ok = await deps.store.delete(id);
6881
+ if (!ok) {
6882
+ sendJSON6(res, 404, { error: "Subscription not found" });
6883
+ return;
6884
+ }
6885
+ deps.bus.emit("webhook.subscription.deleted", { id });
6886
+ sendJSON6(res, 200, { deleted: true });
6887
+ })();
6888
+ return true;
6889
+ }
6890
+ return false;
6891
+ }
6892
+
6893
+ // src/server/routes/v1/telemetry.ts
6894
+ var CACHE_STATS_PATH_RE = /^\/api\/v1\/telemetry\/cache\/stats(?:\?.*)?$/;
6895
+ function sendJSON7(res, status, body) {
6896
+ res.writeHead(status, { "Content-Type": "application/json" });
6897
+ res.end(JSON.stringify(body));
6898
+ }
6899
+ function handleV1TelemetryRoute(req, res, deps) {
6900
+ const url = req.url ?? "";
6901
+ const method = req.method ?? "GET";
6902
+ if (method === "GET" && CACHE_STATS_PATH_RE.test(url)) {
6903
+ if (!deps.cacheMetrics) {
6904
+ sendJSON7(res, 503, { error: "Cache metrics recorder not available" });
6905
+ return true;
6906
+ }
6907
+ sendJSON7(res, 200, deps.cacheMetrics.getStats());
6908
+ return true;
6909
+ }
6910
+ return false;
6911
+ }
6912
+
6531
6913
  // src/server/routes/sessions.ts
6532
6914
  var fs11 = __toESM(require("fs/promises"));
6533
6915
  var path11 = __toESM(require("path"));
6534
- var import_zod10 = require("zod");
6535
- var SessionCreateSchema = import_zod10.z.object({
6536
- sessionId: import_zod10.z.string().min(1)
6916
+ var import_zod13 = require("zod");
6917
+ var SessionCreateSchema = import_zod13.z.object({
6918
+ sessionId: import_zod13.z.string().min(1)
6537
6919
  }).passthrough();
6538
6920
  var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6539
6921
  function isSafeId(id) {
@@ -6620,7 +7002,7 @@ async function handleUpdate(req, res, url, sessionsDir) {
6620
7002
  return;
6621
7003
  }
6622
7004
  const body = await readBody(req);
6623
- const updates = import_zod10.z.record(import_zod10.z.unknown()).parse(JSON.parse(body));
7005
+ const updates = import_zod13.z.record(import_zod13.z.unknown()).parse(JSON.parse(body));
6624
7006
  const sessionFilePath = path11.join(sessionsDir, id, "session.json");
6625
7007
  const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
6626
7008
  await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
@@ -6739,8 +7121,112 @@ function handleStreamsRoute(req, res, recorder) {
6739
7121
  return true;
6740
7122
  }
6741
7123
 
7124
+ // src/server/routes/auth.ts
7125
+ var import_zod14 = require("zod");
7126
+ var import_types22 = require("@harness-engineering/types");
7127
+ var CreateBodySchema = import_zod14.z.object({
7128
+ name: import_zod14.z.string().min(1).max(100),
7129
+ scopes: import_zod14.z.array(import_types22.TokenScopeSchema).min(1),
7130
+ bridgeKind: import_types22.BridgeKindSchema.optional(),
7131
+ tenantId: import_zod14.z.string().optional(),
7132
+ expiresAt: import_zod14.z.string().datetime().optional()
7133
+ });
7134
+ function sendJSON8(res, status, body) {
7135
+ res.writeHead(status, { "Content-Type": "application/json" });
7136
+ res.end(JSON.stringify(body));
7137
+ }
7138
+ async function handlePost(req, res, store) {
7139
+ let raw;
7140
+ try {
7141
+ raw = await readBody(req);
7142
+ } catch (err) {
7143
+ const msg = err instanceof Error ? err.message : "Failed to read body";
7144
+ sendJSON8(res, 413, { error: msg });
7145
+ return;
7146
+ }
7147
+ let json;
7148
+ try {
7149
+ json = JSON.parse(raw);
7150
+ } catch {
7151
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
7152
+ return;
7153
+ }
7154
+ const parsed = CreateBodySchema.safeParse(json);
7155
+ if (!parsed.success) {
7156
+ sendJSON8(res, 422, { error: "Invalid body", issues: parsed.error.issues });
7157
+ return;
7158
+ }
7159
+ try {
7160
+ const input = {
7161
+ name: parsed.data.name,
7162
+ scopes: parsed.data.scopes
7163
+ };
7164
+ if (parsed.data.bridgeKind !== void 0) input.bridgeKind = parsed.data.bridgeKind;
7165
+ if (parsed.data.tenantId !== void 0) input.tenantId = parsed.data.tenantId;
7166
+ if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
7167
+ const result = await store.create(input);
7168
+ const publicRecord = import_types22.AuthTokenPublicSchema.parse(result.record);
7169
+ sendJSON8(res, 200, {
7170
+ ...publicRecord,
7171
+ token: result.token
7172
+ });
7173
+ } catch (err) {
7174
+ const msg = err instanceof Error ? err.message : "Failed to create token";
7175
+ if (msg.includes("already exists")) {
7176
+ sendJSON8(res, 409, { error: msg });
7177
+ return;
7178
+ }
7179
+ sendJSON8(res, 500, { error: "Internal error creating token" });
7180
+ }
7181
+ }
7182
+ async function handleList2(res, store) {
7183
+ try {
7184
+ const list = await store.list();
7185
+ sendJSON8(res, 200, list);
7186
+ } catch {
7187
+ sendJSON8(res, 500, { error: "Internal error listing tokens" });
7188
+ }
7189
+ }
7190
+ async function handleDelete2(res, store, id) {
7191
+ try {
7192
+ const ok = await store.revoke(id);
7193
+ if (!ok) {
7194
+ sendJSON8(res, 404, { error: "Token not found" });
7195
+ return;
7196
+ }
7197
+ sendJSON8(res, 200, { deleted: true });
7198
+ } catch {
7199
+ sendJSON8(res, 500, { error: "Internal error revoking token" });
7200
+ }
7201
+ }
7202
+ var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
7203
+ function handleAuthRoute(req, res, store) {
7204
+ const { method, url } = req;
7205
+ if (!url) return false;
7206
+ if (!url.startsWith("/api/v1/auth/")) return false;
7207
+ const [pathname] = url.split("?");
7208
+ if (method === "POST" && pathname === "/api/v1/auth/token") {
7209
+ void handlePost(req, res, store);
7210
+ return true;
7211
+ }
7212
+ if (method === "GET" && pathname === "/api/v1/auth/tokens") {
7213
+ void handleList2(res, store);
7214
+ return true;
7215
+ }
7216
+ if (method === "DELETE") {
7217
+ const match = (pathname ?? "").match(DELETE_PATH_RE2);
7218
+ if (match && match[1]) {
7219
+ const id = decodeURIComponent(match[1]);
7220
+ void handleDelete2(res, store, id);
7221
+ return true;
7222
+ }
7223
+ }
7224
+ sendJSON8(res, 405, { error: "Method not allowed" });
7225
+ return true;
7226
+ }
7227
+
6742
7228
  // src/server/routes/local-model.ts
6743
- function sendJSON4(res, status, body) {
7229
+ function sendJSON9(res, status, body) {
6744
7230
  res.writeHead(status, { "Content-Type": "application/json" });
6745
7231
  res.end(JSON.stringify(body));
6746
7232
  }
@@ -6748,30 +7234,30 @@ function handleLocalModelRoute(req, res, getStatus) {
6748
7234
  const { method, url } = req;
6749
7235
  if (url !== "/api/v1/local-model/status") return false;
6750
7236
  if (method !== "GET") {
6751
- sendJSON4(res, 405, { error: "Method not allowed" });
7237
+ sendJSON9(res, 405, { error: "Method not allowed" });
6752
7238
  return true;
6753
7239
  }
6754
7240
  if (!getStatus) {
6755
- sendJSON4(res, 503, { error: "Local backend not configured" });
7241
+ sendJSON9(res, 503, { error: "Local backend not configured" });
6756
7242
  return true;
6757
7243
  }
6758
7244
  const status = getStatus();
6759
7245
  if (!status) {
6760
- sendJSON4(res, 503, { error: "Local backend not configured" });
7246
+ sendJSON9(res, 503, { error: "Local backend not configured" });
6761
7247
  return true;
6762
7248
  }
6763
- sendJSON4(res, 200, status);
7249
+ sendJSON9(res, 200, status);
6764
7250
  return true;
6765
7251
  }
6766
7252
  function handleLocalModelsRoute(req, res, getStatuses) {
6767
7253
  const { method, url } = req;
6768
7254
  if (url !== "/api/v1/local-models/status") return false;
6769
7255
  if (method !== "GET") {
6770
- sendJSON4(res, 405, { error: "Method not allowed" });
7256
+ sendJSON9(res, 405, { error: "Method not allowed" });
6771
7257
  return true;
6772
7258
  }
6773
7259
  const statuses = getStatuses ? getStatuses() : [];
6774
- sendJSON4(res, 200, statuses);
7260
+ sendJSON9(res, 200, statuses);
6775
7261
  return true;
6776
7262
  }
6777
7263
 
@@ -6878,10 +7364,268 @@ var PlanWatcher = class {
6878
7364
  }
6879
7365
  };
6880
7366
 
7367
+ // src/auth/tokens.ts
7368
+ var import_node_crypto9 = require("crypto");
7369
+ var import_promises = require("fs/promises");
7370
+ var import_node_path = require("path");
7371
+ var import_bcryptjs = __toESM(require("bcryptjs"));
7372
+ var import_types23 = require("@harness-engineering/types");
7373
+ var BCRYPT_ROUNDS = 12;
7374
+ var LEGACY_ENV_ID = "tok_legacy_env";
7375
+ function genId() {
7376
+ return `tok_${(0, import_node_crypto9.randomBytes)(8).toString("hex")}`;
7377
+ }
7378
+ function genSecret() {
7379
+ return (0, import_node_crypto9.randomBytes)(24).toString("base64url");
7380
+ }
7381
+ function parseToken(raw) {
7382
+ const dot = raw.indexOf(".");
7383
+ if (dot < 0) return null;
7384
+ return { id: raw.slice(0, dot), secret: raw.slice(dot + 1) };
7385
+ }
7386
+ var TokenStore = class {
7387
+ constructor(path17) {
7388
+ this.path = path17;
7389
+ }
7390
+ path;
7391
+ cache = null;
7392
+ async load() {
7393
+ if (this.cache) return this.cache;
7394
+ try {
7395
+ const raw = await (0, import_promises.readFile)(this.path, "utf8");
7396
+ const parsed = JSON.parse(raw);
7397
+ const list = Array.isArray(parsed) ? parsed : [];
7398
+ this.cache = list.map((entry) => {
7399
+ const r = import_types23.AuthTokenSchema.safeParse(entry);
7400
+ return r.success ? r.data : null;
7401
+ }).filter((x) => x !== null);
7402
+ } catch (err) {
7403
+ if (err.code === "ENOENT") this.cache = [];
7404
+ else throw err;
7405
+ }
7406
+ return this.cache;
7407
+ }
7408
+ async persist(records) {
7409
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(this.path), { recursive: true });
7410
+ const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}-${(0, import_node_crypto9.randomBytes)(4).toString("hex")}`;
7411
+ await (0, import_promises.writeFile)(tmp, JSON.stringify(records, null, 2), "utf8");
7412
+ await (0, import_promises.rename)(tmp, this.path);
7413
+ this.cache = records;
7414
+ }
7415
+ async create(input) {
7416
+ const id = genId();
7417
+ const secret = genSecret();
7418
+ const hashedSecret = await import_bcryptjs.default.hash(secret, BCRYPT_ROUNDS);
7419
+ const record = {
7420
+ id,
7421
+ name: input.name,
7422
+ scopes: input.scopes,
7423
+ ...input.bridgeKind ? { bridgeKind: input.bridgeKind } : {},
7424
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
7425
+ hashedSecret,
7426
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7427
+ ...input.expiresAt ? { expiresAt: input.expiresAt } : {}
7428
+ };
7429
+ const records = await this.load();
7430
+ if (records.some((r) => r.name === input.name)) {
7431
+ throw new Error(`Token with name "${input.name}" already exists`);
7432
+ }
7433
+ await this.persist([...records, record]);
7434
+ return { id, token: `${id}.${secret}`, record };
7435
+ }
7436
+ async verify(raw) {
7437
+ const parsed = parseToken(raw);
7438
+ if (!parsed) return null;
7439
+ const records = await this.load();
7440
+ const rec = records.find((r) => r.id === parsed.id);
7441
+ if (!rec) return null;
7442
+ if (rec.expiresAt && Date.parse(rec.expiresAt) <= Date.now()) return null;
7443
+ const ok = await import_bcryptjs.default.compare(parsed.secret, rec.hashedSecret);
7444
+ if (!ok) return null;
7445
+ await this.touchLastUsed(rec.id);
7446
+ return rec;
7447
+ }
7448
+ async touchLastUsed(id) {
7449
+ const records = await this.load();
7450
+ const idx = records.findIndex((r) => r.id === id);
7451
+ if (idx < 0) return;
7452
+ const current = records[idx];
7453
+ if (!current) return;
7454
+ const next = { ...current, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() };
7455
+ const out = records.slice();
7456
+ out[idx] = next;
7457
+ await this.persist(out);
7458
+ }
7459
+ async list() {
7460
+ const records = await this.load();
7461
+ return records.map((r) => import_types23.AuthTokenPublicSchema.parse(r));
7462
+ }
7463
+ async revoke(id) {
7464
+ const records = await this.load();
7465
+ const next = records.filter((r) => r.id !== id);
7466
+ if (next.length === records.length) return false;
7467
+ await this.persist(next);
7468
+ return true;
7469
+ }
7470
+ /**
7471
+ * Synthetic admin record for the legacy HARNESS_API_TOKEN escape hatch.
7472
+ * Returned only when `presented` matches `envValue` byte-for-byte (constant-time).
7473
+ */
7474
+ legacyEnvToken(presented, envValue) {
7475
+ if (!envValue) return null;
7476
+ const a = Buffer.from(presented);
7477
+ const b = Buffer.from(envValue);
7478
+ if (a.length !== b.length) return null;
7479
+ if (!(0, import_node_crypto9.timingSafeEqual)(a, b)) return null;
7480
+ return {
7481
+ id: LEGACY_ENV_ID,
7482
+ name: "legacy-env",
7483
+ scopes: ["admin"],
7484
+ hashedSecret: "<env>",
7485
+ createdAt: (/* @__PURE__ */ new Date(0)).toISOString()
7486
+ };
7487
+ }
7488
+ };
7489
+
7490
+ // src/auth/audit.ts
7491
+ var import_promises2 = require("fs/promises");
7492
+ var import_node_path2 = require("path");
7493
+ var import_types24 = require("@harness-engineering/types");
7494
+ var AuditLogger = class {
7495
+ constructor(path17, opts = {}) {
7496
+ this.path = path17;
7497
+ this.opts = opts;
7498
+ }
7499
+ path;
7500
+ opts;
7501
+ queue = Promise.resolve();
7502
+ dirEnsured = false;
7503
+ async append(input) {
7504
+ const entry = import_types24.AuthAuditEntrySchema.parse({
7505
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7506
+ tokenId: input.tokenId,
7507
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
7508
+ route: input.route,
7509
+ method: input.method,
7510
+ status: input.status
7511
+ });
7512
+ const line = `${JSON.stringify(entry)}
7513
+ `;
7514
+ this.queue = this.queue.then(() => this.writeLine(line)).catch(() => void 0);
7515
+ }
7516
+ /** Wait for queued writes to drain. Test-only; not called on the hot path. */
7517
+ async flush() {
7518
+ await this.queue;
7519
+ }
7520
+ async writeLine(line) {
7521
+ try {
7522
+ if (this.opts.createDir !== false && !this.dirEnsured) {
7523
+ await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(this.path), { recursive: true });
7524
+ this.dirEnsured = true;
7525
+ }
7526
+ await (0, import_promises2.appendFile)(this.path, line, "utf8");
7527
+ } catch (err) {
7528
+ console.warn(`[audit] write failed: ${err.message}`);
7529
+ }
7530
+ }
7531
+ };
7532
+
7533
+ // src/server/v1-bridge-routes.ts
7534
+ var V1_BRIDGE_ROUTES = [
7535
+ // ── Phase 2 bridge primitives ──
7536
+ {
7537
+ method: "POST",
7538
+ pattern: /^\/api\/v1\/jobs\/maintenance(?:\?.*)?$/,
7539
+ scope: "trigger-job",
7540
+ description: "Trigger a maintenance task ad-hoc."
7541
+ },
7542
+ {
7543
+ method: "POST",
7544
+ pattern: /^\/api\/v1\/interactions\/[^/]+\/resolve(?:\?.*)?$/,
7545
+ scope: "resolve-interaction",
7546
+ description: "Resolve a pending interaction."
7547
+ },
7548
+ {
7549
+ method: "GET",
7550
+ pattern: /^\/api\/v1\/events(?:\?.*)?$/,
7551
+ scope: "read-telemetry",
7552
+ description: "Server-Sent Events stream."
7553
+ },
7554
+ // ── Phase 3 bridge primitives ──
7555
+ {
7556
+ method: "POST",
7557
+ pattern: /^\/api\/v1\/webhooks(?:\?.*)?$/,
7558
+ scope: "subscribe-webhook",
7559
+ description: "Subscribe to outbound webhook fan-out."
7560
+ },
7561
+ {
7562
+ method: "DELETE",
7563
+ pattern: /^\/api\/v1\/webhooks\/[^/]+(?:\?.*)?$/,
7564
+ scope: "subscribe-webhook",
7565
+ description: "Delete a webhook subscription."
7566
+ },
7567
+ {
7568
+ method: "GET",
7569
+ pattern: /^\/api\/v1\/webhooks(?:\?.*)?$/,
7570
+ scope: "subscribe-webhook",
7571
+ description: "List webhook subscriptions."
7572
+ },
7573
+ // ── Phase 4 bridge primitives ──
7574
+ {
7575
+ method: "GET",
7576
+ pattern: /^\/api\/v1\/webhooks\/queue\/stats(?:\?.*)?$/,
7577
+ scope: "subscribe-webhook",
7578
+ description: "Webhook delivery queue depth + DLQ stats."
7579
+ },
7580
+ // ── Phase 5 bridge primitives ──
7581
+ {
7582
+ method: "GET",
7583
+ pattern: /^\/api\/v1\/telemetry\/cache\/stats(?:\?.*)?$/,
7584
+ scope: "read-telemetry",
7585
+ description: "Prompt-cache hit/miss snapshot (rolling window)."
7586
+ }
7587
+ ];
7588
+ function isV1Bridge(method, url) {
7589
+ return V1_BRIDGE_ROUTES.some((r) => r.method === method && r.pattern.test(url));
7590
+ }
7591
+ function requiredBridgeScope(method, path17) {
7592
+ for (const r of V1_BRIDGE_ROUTES) {
7593
+ if (r.method === method && r.pattern.test(path17)) return r.scope;
7594
+ }
7595
+ return null;
7596
+ }
7597
+
7598
+ // src/auth/scopes.ts
7599
+ function hasScope(held, required) {
7600
+ if (held.includes("admin")) return true;
7601
+ return held.includes(required);
7602
+ }
7603
+ function requiredScopeForRoute(method, path17) {
7604
+ const bridgeScope = requiredBridgeScope(method, path17);
7605
+ if (bridgeScope) return bridgeScope;
7606
+ if (path17 === "/api/v1/auth/token" && method === "POST") return "admin";
7607
+ if (path17 === "/api/v1/auth/tokens" && method === "GET") return "admin";
7608
+ if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path17) && method === "DELETE") return "admin";
7609
+ if ((path17 === "/api/state" || path17 === "/api/v1/state") && method === "GET") return "read-status";
7610
+ if (path17.startsWith("/api/interactions")) return "resolve-interaction";
7611
+ if (path17.startsWith("/api/plans")) return "read-status";
7612
+ if (path17.startsWith("/api/analyze") || path17.startsWith("/api/analyses")) return "read-status";
7613
+ if (path17.startsWith("/api/roadmap-actions")) return "modify-roadmap";
7614
+ if (path17.startsWith("/api/dispatch-actions")) return "trigger-job";
7615
+ if (path17.startsWith("/api/local-model") || path17.startsWith("/api/local-models"))
7616
+ return "read-status";
7617
+ if (path17.startsWith("/api/maintenance")) return "trigger-job";
7618
+ if (path17.startsWith("/api/streams")) return "read-status";
7619
+ if (path17.startsWith("/api/sessions")) return "read-status";
7620
+ if (path17.startsWith("/api/chat-proxy")) return "trigger-job";
7621
+ return null;
7622
+ }
7623
+
6881
7624
  // src/server/http.ts
6882
7625
  var RATE_LIMIT = Number(process.env["HARNESS_RATE_LIMIT"]) || 100;
6883
7626
  var WINDOW_MS = 6e4;
6884
7627
  var rateBuckets = /* @__PURE__ */ new Map();
7628
+ var DEPRECATION_DATE = process.env["HARNESS_DEPRECATION_DATE"] ?? "2027-05-14";
6885
7629
  var ratePruneTimer = setInterval(() => {
6886
7630
  const now = Date.now();
6887
7631
  for (const [ip, bucket] of rateBuckets) {
@@ -6930,8 +7674,13 @@ var OrchestratorServer = class {
6930
7674
  maintenanceDeps = null;
6931
7675
  getLocalModelStatus = null;
6932
7676
  getLocalModelStatuses = null;
7677
+ webhooks;
7678
+ cacheMetrics;
6933
7679
  recorder = null;
6934
7680
  planWatcher = null;
7681
+ tokenStore;
7682
+ auditLogger;
7683
+ warnedUnauthDev = false;
6935
7684
  stateChangeListener;
6936
7685
  agentEventListener;
6937
7686
  apiRoutes;
@@ -6939,6 +7688,10 @@ var OrchestratorServer = class {
6939
7688
  this.orchestrator = orchestrator;
6940
7689
  this.port = port;
6941
7690
  this.initDependencies(deps);
7691
+ const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path14.resolve(".harness", "tokens.json");
7692
+ const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path14.resolve(".harness", "audit.log");
7693
+ this.tokenStore = new TokenStore(tokensPath);
7694
+ this.auditLogger = new AuditLogger(auditPath);
6942
7695
  this.httpServer = http.createServer(this.handleRequest.bind(this));
6943
7696
  this.broadcaster = new WebSocketBroadcaster(
6944
7697
  this.httpServer,
@@ -6960,6 +7713,8 @@ var OrchestratorServer = class {
6960
7713
  this.maintenanceDeps = deps?.maintenanceDeps ?? null;
6961
7714
  this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
6962
7715
  this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
7716
+ this.webhooks = deps?.webhooks;
7717
+ this.cacheMetrics = deps?.cacheMetrics;
6963
7718
  }
6964
7719
  wireEvents() {
6965
7720
  this.stateChangeListener = (snapshot) => {
@@ -7025,10 +7780,8 @@ var OrchestratorServer = class {
7025
7780
  this.recorder = recorder;
7026
7781
  }
7027
7782
  handleRequest(req, res) {
7028
- if (this.handleStateEndpoint(req, res)) {
7029
- return;
7030
- }
7031
- if (!checkRateLimit(req, res)) {
7783
+ const isState = req.method === "GET" && (req.url === "/api/state" || req.url === "/api/v1/state");
7784
+ if (!isState && !checkRateLimit(req, res)) {
7032
7785
  return;
7033
7786
  }
7034
7787
  if (this.handleApiRoutes(req, res)) {
@@ -7040,31 +7793,48 @@ var OrchestratorServer = class {
7040
7793
  res.writeHead(404, { "Content-Type": "application/json" });
7041
7794
  res.end(JSON.stringify({ error: "Not Found" }));
7042
7795
  }
7043
- /** Handle GET /api/state and legacy /api/v1/state */
7044
- handleStateEndpoint(req, res) {
7045
- const { method, url } = req;
7046
- if (method === "GET" && (url === "/api/state" || url === "/api/v1/state")) {
7047
- res.writeHead(200, { "Content-Type": "application/json" });
7048
- res.end(JSON.stringify(this.orchestrator.getSnapshot()));
7049
- return true;
7050
- }
7051
- return false;
7052
- }
7053
7796
  /**
7054
- * Check bearer token auth for mutating API routes.
7055
- * When HARNESS_API_TOKEN is set, all API requests must include it.
7056
- * Read-only endpoints (state, static) are exempt.
7797
+ * Phase 1 auth: bearer token lookup against TokenStore + scope check.
7798
+ * Legacy HARNESS_API_TOKEN env var still authenticates as a synthetic
7799
+ * admin record (see TokenStore.legacyEnvToken).
7800
+ *
7801
+ * Returns the resolved AuthToken on success; sends 401/403 + returns null on failure.
7057
7802
  */
7058
- checkAuth(req, res) {
7059
- const token = process.env["HARNESS_API_TOKEN"];
7060
- if (!token) return true;
7803
+ async resolveAuth(req, res) {
7061
7804
  const authHeader = req.headers["authorization"];
7062
- if (authHeader === `Bearer ${token}`) return true;
7063
- res.writeHead(401, { "Content-Type": "application/json" });
7064
- res.end(
7065
- JSON.stringify({ error: "Unauthorized \u2014 set Authorization: Bearer <HARNESS_API_TOKEN>" })
7066
- );
7067
- return false;
7805
+ const legacyEnv = process.env["HARNESS_API_TOKEN"];
7806
+ const listed = await this.tokenStore.list().catch(() => []);
7807
+ if (listed.length === 0 && !legacyEnv) {
7808
+ res.setHeader("X-Harness-Auth-Mode", "unauth-dev");
7809
+ if (!this.warnedUnauthDev) {
7810
+ this.warnedUnauthDev = true;
7811
+ console.warn(
7812
+ "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."
7813
+ );
7814
+ }
7815
+ return {
7816
+ id: "tok_unauth_dev",
7817
+ name: "unauth-dev",
7818
+ scopes: ["admin"],
7819
+ hashedSecret: "<none>",
7820
+ createdAt: (/* @__PURE__ */ new Date(0)).toISOString()
7821
+ };
7822
+ }
7823
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
7824
+ res.writeHead(401, { "Content-Type": "application/json" });
7825
+ res.end(JSON.stringify({ error: "Unauthorized \u2014 set Authorization: Bearer <token>" }));
7826
+ return null;
7827
+ }
7828
+ const raw = authHeader.slice("Bearer ".length).trim();
7829
+ const legacyMatch = this.tokenStore.legacyEnvToken(raw, legacyEnv);
7830
+ if (legacyMatch) return legacyMatch;
7831
+ const verified = await this.tokenStore.verify(raw);
7832
+ if (!verified) {
7833
+ res.writeHead(401, { "Content-Type": "application/json" });
7834
+ res.end(JSON.stringify({ error: "Unauthorized \u2014 invalid or expired token" }));
7835
+ return null;
7836
+ }
7837
+ return verified;
7068
7838
  }
7069
7839
  /**
7070
7840
  * Build the ordered API route table. Each entry is invoked in order and
@@ -7076,7 +7846,11 @@ var OrchestratorServer = class {
7076
7846
  */
7077
7847
  buildApiRoutes() {
7078
7848
  return [
7849
+ // Auth admin routes — scope-gated to `admin` by requiredScopeForRoute.
7850
+ // First in the table so the auth surface is unambiguously owned by the orchestrator.
7851
+ (req, res) => handleAuthRoute(req, res, this.tokenStore),
7079
7852
  (req, res) => !!this.interactionQueue && handleInteractionsRoute(req, res, this.interactionQueue),
7853
+ (req, res) => !!this.interactionQueue && handleV1InteractionsResolveRoute(req, res, this.interactionQueue),
7080
7854
  (req, res) => handlePlansRoute(req, res, this.plansDir),
7081
7855
  (req, res) => handleAnalyzeRoute(req, res, this.pipeline),
7082
7856
  (req, res) => handleAnalysesRoute(req, res, this.analysisArchive),
@@ -7086,19 +7860,103 @@ var OrchestratorServer = class {
7086
7860
  // Local-models multi-status route (Spec 2 SC38)
7087
7861
  (req, res) => handleLocalModelsRoute(req, res, this.getLocalModelStatuses),
7088
7862
  (req, res) => handleMaintenanceRoute(req, res, this.maintenanceDeps),
7863
+ (req, res) => handleV1JobsMaintenanceRoute(req, res, this.maintenanceDeps),
7089
7864
  (req, res) => !!this.recorder && handleStreamsRoute(req, res, this.recorder),
7090
7865
  (req, res) => handleSessionsRoute(req, res, this.sessionsDir),
7866
+ // SSE event stream — long-lived; placed near end so cheaper routes
7867
+ // short-circuit first, but before the chat-proxy fallback.
7868
+ (req, res) => handleV1EventsSseRoute(req, res, this.orchestrator),
7869
+ // Phase 3 webhooks — short-circuits to false when webhooks is undefined
7870
+ // (e.g. FakeOrchestrator-based tests pass no webhooks dep).
7871
+ // Phase 4: forward the optional queue handle so the stats endpoint can
7872
+ // serve depth/DLQ counts.
7873
+ (req, res) => !!this.webhooks && handleV1WebhooksRoute(req, res, {
7874
+ store: this.webhooks.store,
7875
+ bus: this.orchestrator,
7876
+ ...this.webhooks.queue ? { queue: this.webhooks.queue } : {}
7877
+ }),
7878
+ // Phase 5 — telemetry/cache/stats. Returns 503 when cacheMetrics is unset
7879
+ // (FakeOrchestrator tests, exporter-disabled configs).
7880
+ (req, res) => handleV1TelemetryRoute(req, res, {
7881
+ ...this.cacheMetrics ? { cacheMetrics: this.cacheMetrics } : {}
7882
+ }),
7091
7883
  // Chat proxy route (spawns Claude Code CLI — no API key required)
7092
7884
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
7093
7885
  ];
7094
7886
  }
7095
- /** Dispatch to API route handlers. Returns true if a route matched. */
7887
+ /**
7888
+ * Dispatch to API route handlers. Returns true immediately and resolves the
7889
+ * request asynchronously (auth + scope check + dispatch + audit log).
7890
+ *
7891
+ * Static-file fallback for non-/api/* requests requires returning false so
7892
+ * `handleRequest` can hand the request off to the static handler.
7893
+ */
7096
7894
  handleApiRoutes(req, res) {
7097
- if (!this.checkAuth(req, res)) return true;
7895
+ const url = req.url ?? "";
7896
+ if (!url.startsWith("/api/")) return false;
7897
+ void this.dispatchAuthedRequest(req, res);
7898
+ return true;
7899
+ }
7900
+ async dispatchAuthedRequest(req, res) {
7901
+ const token = await this.resolveAuth(req, res);
7902
+ res.on("finish", () => this.audit(req, res, token));
7903
+ if (!token) return;
7904
+ req._authToken = {
7905
+ id: token.id,
7906
+ scopes: token.scopes
7907
+ };
7908
+ const V1_WRAPPABLE = /* @__PURE__ */ new Set([
7909
+ "interactions",
7910
+ "plans",
7911
+ "analyze",
7912
+ "analyses",
7913
+ "roadmap-actions",
7914
+ "dispatch-actions",
7915
+ "local-model",
7916
+ "local-models",
7917
+ "maintenance",
7918
+ "streams",
7919
+ "sessions",
7920
+ "chat-proxy"
7921
+ ]);
7922
+ const v1Match = /^\/api\/v1\/([^/?]+)(.*)$/.exec(req.url ?? "");
7923
+ const rewrittenSlug = v1Match?.[1];
7924
+ const v1BridgeMatch = isV1Bridge(req.method ?? "GET", req.url ?? "");
7925
+ if (!v1BridgeMatch && rewrittenSlug && V1_WRAPPABLE.has(rewrittenSlug)) {
7926
+ req.url = `/api/${rewrittenSlug}${v1Match?.[2] ?? ""}`;
7927
+ }
7928
+ const isLegacyPrefix = !!req.url && // eslint-disable-next-line @harness-engineering/no-hardcoded-path-separator -- URL path, not filesystem
7929
+ req.url.startsWith("/api/") && // eslint-disable-next-line @harness-engineering/no-hardcoded-path-separator -- URL path, not filesystem
7930
+ !req.url.startsWith("/api/v1/") && !v1Match;
7931
+ if (isLegacyPrefix) {
7932
+ res.setHeader("Deprecation", DEPRECATION_DATE);
7933
+ }
7934
+ const pathname = (req.url ?? "").split("?")[0] ?? "";
7935
+ const required = requiredScopeForRoute(req.method ?? "GET", pathname);
7936
+ if (!required || !hasScope(token.scopes, required)) {
7937
+ res.writeHead(403, { "Content-Type": "application/json" });
7938
+ res.end(JSON.stringify({ error: "Insufficient scope", required: required ?? "unknown" }));
7939
+ return;
7940
+ }
7941
+ if (req.method === "GET" && (req.url === "/api/state" || req.url === "/api/v1/state")) {
7942
+ res.writeHead(200, { "Content-Type": "application/json" });
7943
+ res.end(JSON.stringify(this.orchestrator.getSnapshot()));
7944
+ return;
7945
+ }
7098
7946
  for (const route of this.apiRoutes) {
7099
- if (route(req, res)) return true;
7947
+ if (route(req, res)) return;
7100
7948
  }
7101
- return false;
7949
+ res.writeHead(404, { "Content-Type": "application/json" });
7950
+ res.end(JSON.stringify({ error: "Not Found" }));
7951
+ }
7952
+ audit(req, res, token) {
7953
+ void this.auditLogger.append({
7954
+ tokenId: token?.id ?? "anonymous",
7955
+ ...token?.tenantId ? { tenantId: token.tenantId } : {},
7956
+ route: (req.url ?? "").split("?")[0] ?? "",
7957
+ method: req.method ?? "GET",
7958
+ status: res.statusCode || 0
7959
+ });
7102
7960
  }
7103
7961
  get wsClientCount() {
7104
7962
  return this.broadcaster.clientCount;
@@ -7129,6 +7987,630 @@ var OrchestratorServer = class {
7129
7987
  }
7130
7988
  };
7131
7989
 
7990
+ // src/gateway/webhooks/store.ts
7991
+ var import_node_crypto11 = require("crypto");
7992
+ var import_promises3 = require("fs/promises");
7993
+ var import_node_path3 = require("path");
7994
+ var import_types25 = require("@harness-engineering/types");
7995
+
7996
+ // src/gateway/webhooks/signer.ts
7997
+ var import_node_crypto10 = require("crypto");
7998
+ function sign(secret, body) {
7999
+ const hex = (0, import_node_crypto10.createHmac)("sha256", secret).update(body).digest("hex");
8000
+ return `sha256=${hex}`;
8001
+ }
8002
+ function eventMatches(pattern, type) {
8003
+ if (type.startsWith("telemetry.")) {
8004
+ const pSegs2 = pattern.split(".");
8005
+ if (pSegs2[0] !== "telemetry") return false;
8006
+ }
8007
+ const pSegs = pattern.split(".");
8008
+ const tSegs = type.split(".");
8009
+ if (pSegs.length !== tSegs.length) return false;
8010
+ for (let i = 0; i < pSegs.length; i++) {
8011
+ if (pSegs[i] !== "*" && pSegs[i] !== tSegs[i]) return false;
8012
+ }
8013
+ return true;
8014
+ }
8015
+
8016
+ // src/gateway/webhooks/store.ts
8017
+ function genId2() {
8018
+ return `whk_${(0, import_node_crypto11.randomBytes)(8).toString("hex")}`;
8019
+ }
8020
+ function genSecret2() {
8021
+ return (0, import_node_crypto11.randomBytes)(32).toString("base64url");
8022
+ }
8023
+ var WebhookStore = class {
8024
+ constructor(path17) {
8025
+ this.path = path17;
8026
+ }
8027
+ path;
8028
+ cache = null;
8029
+ async load() {
8030
+ if (this.cache) return this.cache;
8031
+ try {
8032
+ const raw = await (0, import_promises3.readFile)(this.path, "utf8");
8033
+ const parsed = JSON.parse(raw);
8034
+ const list = Array.isArray(parsed) ? parsed : [];
8035
+ this.cache = list.map((entry) => {
8036
+ const r = import_types25.WebhookSubscriptionSchema.safeParse(entry);
8037
+ return r.success ? r.data : null;
8038
+ }).filter((x) => x !== null);
8039
+ } catch (err) {
8040
+ if (err.code === "ENOENT") this.cache = [];
8041
+ else throw err;
8042
+ }
8043
+ return this.cache;
8044
+ }
8045
+ async persist(records) {
8046
+ const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}-${(0, import_node_crypto11.randomBytes)(4).toString("hex")}`;
8047
+ try {
8048
+ await (0, import_promises3.mkdir)((0, import_node_path3.dirname)(this.path), { recursive: true });
8049
+ await (0, import_promises3.writeFile)(tmp, JSON.stringify(records, null, 2), { encoding: "utf8", mode: 384 });
8050
+ await (0, import_promises3.rename)(tmp, this.path);
8051
+ await (0, import_promises3.chmod)(this.path, 384);
8052
+ } catch (err) {
8053
+ if (err.code !== "ENOENT") throw err;
8054
+ }
8055
+ this.cache = records;
8056
+ }
8057
+ async create(input) {
8058
+ const id = genId2();
8059
+ const secret = genSecret2();
8060
+ const record = {
8061
+ id,
8062
+ tokenId: input.tokenId,
8063
+ url: input.url,
8064
+ events: input.events,
8065
+ secret,
8066
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
8067
+ };
8068
+ const records = await this.load();
8069
+ await this.persist([...records, record]);
8070
+ return record;
8071
+ }
8072
+ async list() {
8073
+ return [...await this.load()];
8074
+ }
8075
+ async delete(id) {
8076
+ const records = await this.load();
8077
+ const next = records.filter((r) => r.id !== id);
8078
+ if (next.length === records.length) return false;
8079
+ await this.persist(next);
8080
+ return true;
8081
+ }
8082
+ /** Returns subs whose events list contains a pattern matching `eventType`. */
8083
+ async listForEvent(eventType) {
8084
+ const records = await this.load();
8085
+ return records.filter((r) => r.events.some((p) => eventMatches(p, eventType)));
8086
+ }
8087
+ };
8088
+
8089
+ // src/gateway/webhooks/delivery.ts
8090
+ var import_node_crypto12 = require("crypto");
8091
+
8092
+ // src/gateway/webhooks/queue.ts
8093
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"));
8094
+ var RETRY_DELAYS_MS = [1e3, 4e3, 16e3, 64e3, 256e3];
8095
+ var MAX_ATTEMPTS = 5;
8096
+ var SCHEMA_SQL = `
8097
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
8098
+ id TEXT PRIMARY KEY,
8099
+ subscriptionId TEXT NOT NULL,
8100
+ eventType TEXT NOT NULL,
8101
+ payload TEXT NOT NULL,
8102
+ attempt INTEGER NOT NULL DEFAULT 0,
8103
+ status TEXT NOT NULL DEFAULT 'pending',
8104
+ nextAttemptAt INTEGER,
8105
+ lastError TEXT,
8106
+ deliveredAt INTEGER
8107
+ ) STRICT;
8108
+ CREATE INDEX IF NOT EXISTS idx_deliverable
8109
+ ON webhook_deliveries(status, nextAttemptAt)
8110
+ WHERE status IN ('pending', 'failed', 'in_flight');
8111
+ `;
8112
+ var WebhookQueue = class {
8113
+ db;
8114
+ constructor(dbPath) {
8115
+ this.db = new import_better_sqlite3.default(dbPath);
8116
+ this.db.pragma("journal_mode = WAL");
8117
+ this.db.pragma("synchronous = NORMAL");
8118
+ this.db.exec(SCHEMA_SQL);
8119
+ this.recoverInFlight();
8120
+ }
8121
+ insert(row) {
8122
+ this.db.prepare(
8123
+ `INSERT INTO webhook_deliveries
8124
+ (id, subscriptionId, eventType, payload, attempt, status, nextAttemptAt)
8125
+ VALUES (@id, @subscriptionId, @eventType, @payload, 0, 'pending', @nextAttemptAt)`
8126
+ ).run({ ...row, nextAttemptAt: Date.now() });
8127
+ }
8128
+ /**
8129
+ * Atomically lease a batch of deliverable rows: select pending|failed rows
8130
+ * whose nextAttemptAt has elapsed, mark them in_flight in the same
8131
+ * transaction, and return the leased rows. Subsequent calls cannot re-claim
8132
+ * the same rows because they are no longer in pending|failed.
8133
+ *
8134
+ * Without this, two overlapping ticks (tick interval 500ms, HTTP timeout
8135
+ * 5s) would both select the same row and double-fire the webhook.
8136
+ */
8137
+ claim(now, limit = 20) {
8138
+ const selectIds = this.db.prepare(
8139
+ `SELECT id FROM webhook_deliveries
8140
+ WHERE (status = 'pending' OR status = 'failed')
8141
+ AND nextAttemptAt <= ?
8142
+ ORDER BY nextAttemptAt
8143
+ LIMIT ?`
8144
+ );
8145
+ const markInFlight = this.db.prepare(
8146
+ `UPDATE webhook_deliveries SET status = 'in_flight' WHERE id = ?`
8147
+ );
8148
+ const fetchById = this.db.prepare(`SELECT * FROM webhook_deliveries WHERE id = ?`);
8149
+ const txn = this.db.transaction((tNow, tLimit) => {
8150
+ const ids = selectIds.all(tNow, tLimit);
8151
+ const claimed = [];
8152
+ for (const { id } of ids) {
8153
+ markInFlight.run(id);
8154
+ const row = fetchById.get(id);
8155
+ if (row) claimed.push(row);
8156
+ }
8157
+ return claimed;
8158
+ });
8159
+ return txn(now, limit);
8160
+ }
8161
+ /**
8162
+ * Reset any rows stuck in in_flight (e.g. from a crashed or abruptly
8163
+ * stopped worker) back to failed so they can be re-claimed by the next
8164
+ * tick. At-most-once semantics within a single process; at-least-once
8165
+ * across restarts (a row whose HTTP POST completed but whose DB update was
8166
+ * lost will be re-delivered — bridges must be idempotent on delivery-id).
8167
+ */
8168
+ recoverInFlight() {
8169
+ return this.db.prepare(
8170
+ `UPDATE webhook_deliveries
8171
+ SET status = 'failed', nextAttemptAt = ?
8172
+ WHERE status = 'in_flight'`
8173
+ ).run(Date.now()).changes;
8174
+ }
8175
+ markDelivered(id, deliveredAt) {
8176
+ this.db.prepare(
8177
+ `UPDATE webhook_deliveries
8178
+ SET status = 'delivered', deliveredAt = ?, nextAttemptAt = NULL
8179
+ WHERE id = ?`
8180
+ ).run(deliveredAt, id);
8181
+ }
8182
+ markFailed(id, attempt, nextAttemptAt, lastError) {
8183
+ if (attempt >= MAX_ATTEMPTS) {
8184
+ this.db.prepare(
8185
+ `UPDATE webhook_deliveries
8186
+ SET status = 'dead', attempt = ?, lastError = ?, nextAttemptAt = NULL
8187
+ WHERE id = ?`
8188
+ ).run(attempt, lastError, id);
8189
+ } else {
8190
+ this.db.prepare(
8191
+ `UPDATE webhook_deliveries
8192
+ SET status = 'failed', attempt = ?, nextAttemptAt = ?, lastError = ?
8193
+ WHERE id = ?`
8194
+ ).run(attempt, nextAttemptAt, lastError, id);
8195
+ }
8196
+ }
8197
+ retryDead(id) {
8198
+ const result = this.db.prepare(
8199
+ `UPDATE webhook_deliveries
8200
+ SET status = 'pending', attempt = 0, nextAttemptAt = ?, lastError = NULL
8201
+ WHERE id = ? AND status = 'dead'`
8202
+ ).run(Date.now(), id);
8203
+ return result.changes > 0;
8204
+ }
8205
+ list(filter = {}) {
8206
+ const conditions = ["1=1"];
8207
+ const params = [];
8208
+ if (filter.status) {
8209
+ conditions.push("status = ?");
8210
+ params.push(filter.status);
8211
+ }
8212
+ if (filter.subscriptionId) {
8213
+ conditions.push("subscriptionId = ?");
8214
+ params.push(filter.subscriptionId);
8215
+ }
8216
+ const sql = `SELECT * FROM webhook_deliveries WHERE ${conditions.join(" AND ")} ORDER BY nextAttemptAt DESC LIMIT 200`;
8217
+ return this.db.prepare(sql).all(...params);
8218
+ }
8219
+ purge(opts = {}) {
8220
+ const conditions = ["1=1"];
8221
+ const params = [];
8222
+ if (opts.deadOnly) {
8223
+ conditions.push("status = 'dead'");
8224
+ }
8225
+ if (opts.olderThanMs !== void 0) {
8226
+ const cutoff = Date.now() - opts.olderThanMs;
8227
+ conditions.push("(deliveredAt IS NOT NULL AND deliveredAt < ?)");
8228
+ params.push(cutoff);
8229
+ }
8230
+ const sql = `DELETE FROM webhook_deliveries WHERE ${conditions.join(" AND ")}`;
8231
+ return this.db.prepare(sql).run(...params).changes;
8232
+ }
8233
+ /**
8234
+ * Count the rows a purge() call with these same options would delete.
8235
+ * Used by the CLI to show a confirmation preview before destructive deletes.
8236
+ */
8237
+ previewPurge(opts = {}) {
8238
+ const conditions = ["1=1"];
8239
+ const params = [];
8240
+ if (opts.deadOnly) {
8241
+ conditions.push("status = 'dead'");
8242
+ }
8243
+ if (opts.olderThanMs !== void 0) {
8244
+ const cutoff = Date.now() - opts.olderThanMs;
8245
+ conditions.push("(deliveredAt IS NOT NULL AND deliveredAt < ?)");
8246
+ params.push(cutoff);
8247
+ }
8248
+ const sql = `SELECT COUNT(*) as count FROM webhook_deliveries WHERE ${conditions.join(" AND ")}`;
8249
+ const row = this.db.prepare(sql).get(...params);
8250
+ return row.count;
8251
+ }
8252
+ stats() {
8253
+ const rows = this.db.prepare(`SELECT status, COUNT(*) as count FROM webhook_deliveries GROUP BY status`).all();
8254
+ const m = Object.fromEntries(rows.map((r) => [r.status, r.count]));
8255
+ return {
8256
+ pending: m["pending"] ?? 0,
8257
+ inFlight: m["in_flight"] ?? 0,
8258
+ failed: m["failed"] ?? 0,
8259
+ dead: m["dead"] ?? 0,
8260
+ delivered: m["delivered"] ?? 0
8261
+ };
8262
+ }
8263
+ close() {
8264
+ this.db.close();
8265
+ }
8266
+ };
8267
+
8268
+ // src/gateway/webhooks/delivery.ts
8269
+ var WebhookDelivery = class {
8270
+ queue;
8271
+ store;
8272
+ timeoutMs;
8273
+ fetchImpl;
8274
+ tickIntervalMs;
8275
+ maxConcurrentPerSub;
8276
+ drainTimeoutMs;
8277
+ allowPrivateHosts;
8278
+ inFlight = /* @__PURE__ */ new Map();
8279
+ /**
8280
+ * AbortControllers for currently executing HTTP POSTs, keyed by delivery id.
8281
+ * On drain-timeout exhaustion stop() aborts each one so we never write to
8282
+ * the SQLite handle after orchestrator.stop() closes it.
8283
+ */
8284
+ inFlightAborts = /* @__PURE__ */ new Map();
8285
+ tickTimer = null;
8286
+ draining = false;
8287
+ constructor(opts) {
8288
+ this.queue = opts.queue;
8289
+ this.store = opts.store;
8290
+ this.timeoutMs = opts.timeoutMs ?? 5e3;
8291
+ this.fetchImpl = opts.fetchImpl ?? fetch;
8292
+ this.tickIntervalMs = opts.tickIntervalMs ?? 500;
8293
+ this.maxConcurrentPerSub = opts.maxConcurrentPerSub ?? 4;
8294
+ this.drainTimeoutMs = opts.drainTimeoutMs ?? 3e4;
8295
+ this.allowPrivateHosts = opts.allowPrivateHosts ?? false;
8296
+ }
8297
+ enqueue(sub, event) {
8298
+ const payload = JSON.stringify(event);
8299
+ this.queue.insert({
8300
+ id: `dlv_${(0, import_node_crypto12.randomBytes)(8).toString("hex")}`,
8301
+ subscriptionId: sub.id,
8302
+ eventType: event.type,
8303
+ payload
8304
+ });
8305
+ }
8306
+ start() {
8307
+ if (this.tickTimer !== null) return;
8308
+ this.tickTimer = setInterval(() => void this.tick(), this.tickIntervalMs);
8309
+ }
8310
+ async stop() {
8311
+ this.draining = true;
8312
+ if (this.tickTimer !== null) {
8313
+ clearInterval(this.tickTimer);
8314
+ this.tickTimer = null;
8315
+ }
8316
+ const deadline = Date.now() + this.drainTimeoutMs;
8317
+ while (Date.now() < deadline) {
8318
+ const total = [...this.inFlight.values()].reduce((a, b) => a + b, 0);
8319
+ if (total === 0) break;
8320
+ await new Promise((r) => setTimeout(r, 100));
8321
+ }
8322
+ if (this.inFlightAborts.size > 0) {
8323
+ for (const ctrl of this.inFlightAborts.values()) ctrl.abort();
8324
+ await new Promise((r) => setTimeout(r, 100));
8325
+ }
8326
+ }
8327
+ async tick() {
8328
+ if (this.draining) return;
8329
+ const pending = this.queue.claim(Date.now());
8330
+ for (const row of pending) {
8331
+ const inFlight = this.inFlight.get(row.subscriptionId) ?? 0;
8332
+ if (inFlight >= this.maxConcurrentPerSub) continue;
8333
+ this.inFlight.set(row.subscriptionId, inFlight + 1);
8334
+ void this.executeDelivery(row);
8335
+ }
8336
+ }
8337
+ async executeDelivery(row) {
8338
+ const ctrl = new AbortController();
8339
+ this.inFlightAborts.set(row.id, ctrl);
8340
+ try {
8341
+ const subs = await this.store.list();
8342
+ const sub = subs.find((s) => s.id === row.subscriptionId);
8343
+ if (!sub) {
8344
+ this.queue.markFailed(row.id, MAX_ATTEMPTS, Date.now(), "subscription deleted");
8345
+ return;
8346
+ }
8347
+ let hostname;
8348
+ try {
8349
+ hostname = new URL(sub.url).hostname;
8350
+ } catch {
8351
+ this.queue.markFailed(row.id, MAX_ATTEMPTS, Date.now(), "invalid URL");
8352
+ return;
8353
+ }
8354
+ if (!this.allowPrivateHosts && isPrivateHost(hostname)) {
8355
+ this.queue.markFailed(
8356
+ row.id,
8357
+ MAX_ATTEMPTS,
8358
+ Date.now(),
8359
+ "URL resolves to private/loopback host"
8360
+ );
8361
+ return;
8362
+ }
8363
+ const signature = sign(sub.secret, row.payload);
8364
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
8365
+ let ok = false;
8366
+ let lastError = "";
8367
+ let aborted = false;
8368
+ try {
8369
+ const res = await this.fetchImpl(sub.url, {
8370
+ method: "POST",
8371
+ headers: {
8372
+ "Content-Type": "application/json",
8373
+ "X-Harness-Delivery-Id": row.id,
8374
+ "X-Harness-Event-Type": row.eventType,
8375
+ "X-Harness-Signature": signature,
8376
+ "X-Harness-Timestamp": String(Date.now())
8377
+ },
8378
+ body: row.payload,
8379
+ signal: ctrl.signal
8380
+ });
8381
+ ok = res.ok;
8382
+ if (!ok) lastError = `HTTP ${res.status}`;
8383
+ } catch (err) {
8384
+ aborted = ctrl.signal.aborted;
8385
+ lastError = err instanceof Error ? err.message : String(err);
8386
+ } finally {
8387
+ clearTimeout(timer);
8388
+ }
8389
+ if (aborted && this.draining) {
8390
+ return;
8391
+ }
8392
+ if (ok) {
8393
+ this.queue.markDelivered(row.id, Date.now());
8394
+ } else {
8395
+ const nextAttempt = row.attempt + 1;
8396
+ const delay = RETRY_DELAYS_MS[row.attempt] ?? 256e3;
8397
+ this.queue.markFailed(row.id, nextAttempt, Date.now() + delay, lastError);
8398
+ }
8399
+ } finally {
8400
+ this.inFlightAborts.delete(row.id);
8401
+ const cur = this.inFlight.get(row.subscriptionId) ?? 1;
8402
+ this.inFlight.set(row.subscriptionId, Math.max(0, cur - 1));
8403
+ }
8404
+ }
8405
+ };
8406
+
8407
+ // src/gateway/webhooks/events.ts
8408
+ var import_node_crypto13 = require("crypto");
8409
+ var WEBHOOK_TOPICS = [
8410
+ "interaction.created",
8411
+ "interaction.resolved",
8412
+ "maintenance:started",
8413
+ "maintenance:completed",
8414
+ "maintenance:error",
8415
+ "webhook.subscription.created",
8416
+ "webhook.subscription.deleted"
8417
+ ];
8418
+ function newEventId2() {
8419
+ return `evt_${(0, import_node_crypto13.randomBytes)(8).toString("hex")}`;
8420
+ }
8421
+ function wireWebhookFanout({ bus, store, delivery }) {
8422
+ const handlers = [];
8423
+ for (const topic of WEBHOOK_TOPICS) {
8424
+ const eventType = topic.replace(":", ".");
8425
+ const fn = (data) => {
8426
+ void (async () => {
8427
+ const subs = await store.listForEvent(eventType);
8428
+ if (subs.length === 0) return;
8429
+ const event = {
8430
+ id: newEventId2(),
8431
+ type: eventType,
8432
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8433
+ data
8434
+ };
8435
+ for (const sub of subs) {
8436
+ delivery.enqueue(sub, event);
8437
+ }
8438
+ })();
8439
+ };
8440
+ bus.on(topic, fn);
8441
+ handlers.push({ topic, fn });
8442
+ }
8443
+ return () => {
8444
+ for (const { topic, fn } of handlers) bus.removeListener(topic, fn);
8445
+ };
8446
+ }
8447
+
8448
+ // src/gateway/telemetry/fanout.ts
8449
+ var import_node_crypto14 = require("crypto");
8450
+ var import_core9 = require("@harness-engineering/core");
8451
+ var TOPICS = {
8452
+ MAINTENANCE_STARTED: "maintenance:started",
8453
+ MAINTENANCE_COMPLETED: "maintenance:completed",
8454
+ MAINTENANCE_ERROR: "maintenance:error",
8455
+ DISPATCH_DECISION: "dispatch:decision",
8456
+ SKILL_INVOCATION: "skill_invocation"
8457
+ };
8458
+ var TELEMETRY_TYPE = {
8459
+ [TOPICS.MAINTENANCE_STARTED]: "telemetry.maintenance_run",
8460
+ [TOPICS.MAINTENANCE_COMPLETED]: "telemetry.maintenance_run",
8461
+ [TOPICS.MAINTENANCE_ERROR]: "telemetry.maintenance_run",
8462
+ [TOPICS.DISPATCH_DECISION]: "telemetry.dispatch_decision",
8463
+ [TOPICS.SKILL_INVOCATION]: "telemetry.skill_invocation"
8464
+ };
8465
+ var SPAN_NAME = {
8466
+ [TOPICS.MAINTENANCE_STARTED]: "maintenance_run",
8467
+ [TOPICS.MAINTENANCE_COMPLETED]: "maintenance_run",
8468
+ [TOPICS.MAINTENANCE_ERROR]: "maintenance_run",
8469
+ [TOPICS.DISPATCH_DECISION]: "dispatch_decision",
8470
+ [TOPICS.SKILL_INVOCATION]: "skill_invocation"
8471
+ };
8472
+ function newEventId3() {
8473
+ return `evt_${(0, import_node_crypto14.randomBytes)(8).toString("hex")}`;
8474
+ }
8475
+ function newTraceId() {
8476
+ return (0, import_node_crypto14.randomBytes)(16).toString("hex");
8477
+ }
8478
+ function newSpanId() {
8479
+ return (0, import_node_crypto14.randomBytes)(8).toString("hex");
8480
+ }
8481
+ function nowNs() {
8482
+ return BigInt(Date.now()) * 1000000n;
8483
+ }
8484
+ var MAX_ACTIVE_RUNS = 256;
8485
+ var ActiveRunRegistry = class {
8486
+ byKey = /* @__PURE__ */ new Map();
8487
+ open(key, ids) {
8488
+ if (this.byKey.has(key)) {
8489
+ this.byKey.set(key, ids);
8490
+ return;
8491
+ }
8492
+ if (this.byKey.size >= MAX_ACTIVE_RUNS) {
8493
+ const oldest = this.byKey.keys().next().value;
8494
+ if (oldest !== void 0) this.byKey.delete(oldest);
8495
+ }
8496
+ this.byKey.set(key, ids);
8497
+ }
8498
+ /**
8499
+ * Look up an active run; tries `correlationId`, then `taskId`. Returns
8500
+ * `undefined` when neither matches — the caller should treat the event as
8501
+ * a root span rather than guessing a parent.
8502
+ */
8503
+ resolve(args) {
8504
+ if (args.correlationId && this.byKey.has(args.correlationId)) {
8505
+ return this.byKey.get(args.correlationId);
8506
+ }
8507
+ if (args.taskId && this.byKey.has(args.taskId)) {
8508
+ return this.byKey.get(args.taskId);
8509
+ }
8510
+ return void 0;
8511
+ }
8512
+ close(key) {
8513
+ this.byKey.delete(key);
8514
+ }
8515
+ /** Number of currently tracked runs. Exposed for tests + diagnostics. */
8516
+ get size() {
8517
+ return this.byKey.size;
8518
+ }
8519
+ };
8520
+ function buildAttributes(payload, extras = {}) {
8521
+ const attrs = { ...extras };
8522
+ for (const [k, v] of Object.entries(payload)) {
8523
+ if (v === null || v === void 0) continue;
8524
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
8525
+ attrs[k] = v;
8526
+ }
8527
+ }
8528
+ return attrs;
8529
+ }
8530
+ function wireTelemetryFanout(params) {
8531
+ const { bus, exporter, webhookDelivery, store } = params;
8532
+ const registry = new ActiveRunRegistry();
8533
+ const handlers = [];
8534
+ const enqueueToMatchingSubs = async (event) => {
8535
+ const subs = await store.listForEvent(event.type);
8536
+ if (subs.length === 0) return;
8537
+ const filtered = subs.filter((sub) => sub.events.some((p) => eventMatches(p, event.type)));
8538
+ for (const sub of filtered) {
8539
+ webhookDelivery.enqueue(sub, event);
8540
+ }
8541
+ };
8542
+ const makeHandler = (topic) => (data) => {
8543
+ const payload = data ?? {};
8544
+ const correlationId = typeof payload["correlationId"] === "string" ? payload["correlationId"] : void 0;
8545
+ const taskId = typeof payload["taskId"] === "string" ? payload["taskId"] : void 0;
8546
+ let traceId;
8547
+ let spanId;
8548
+ let parentSpanId;
8549
+ let statusCode;
8550
+ if (topic === TOPICS.MAINTENANCE_STARTED) {
8551
+ traceId = newTraceId();
8552
+ spanId = newSpanId();
8553
+ const key = correlationId ?? taskId ?? `run_${spanId}`;
8554
+ registry.open(key, { traceId, spanId });
8555
+ } else if (topic === TOPICS.MAINTENANCE_COMPLETED || topic === TOPICS.MAINTENANCE_ERROR) {
8556
+ const existing = registry.resolve({
8557
+ ...correlationId !== void 0 ? { correlationId } : {},
8558
+ ...taskId !== void 0 ? { taskId } : {}
8559
+ });
8560
+ if (existing !== void 0) {
8561
+ traceId = existing.traceId;
8562
+ spanId = existing.spanId;
8563
+ } else {
8564
+ traceId = newTraceId();
8565
+ spanId = newSpanId();
8566
+ }
8567
+ statusCode = topic === TOPICS.MAINTENANCE_ERROR ? 2 : 1;
8568
+ const key = correlationId ?? taskId ?? "";
8569
+ if (key) registry.close(key);
8570
+ } else {
8571
+ const parent = registry.resolve({
8572
+ ...correlationId !== void 0 ? { correlationId } : {},
8573
+ ...taskId !== void 0 ? { taskId } : {}
8574
+ });
8575
+ traceId = parent?.traceId ?? newTraceId();
8576
+ spanId = newSpanId();
8577
+ parentSpanId = parent?.spanId;
8578
+ }
8579
+ const startNs = nowNs();
8580
+ const span = {
8581
+ traceId,
8582
+ spanId,
8583
+ ...parentSpanId !== void 0 ? { parentSpanId } : {},
8584
+ name: SPAN_NAME[topic],
8585
+ kind: import_core9.SpanKind.INTERNAL,
8586
+ startTimeNs: startNs,
8587
+ endTimeNs: startNs,
8588
+ attributes: buildAttributes(payload, { "harness.topic": topic }),
8589
+ ...statusCode !== void 0 ? { statusCode } : {}
8590
+ };
8591
+ exporter.push(span);
8592
+ const gatewayEvent = {
8593
+ id: newEventId3(),
8594
+ type: TELEMETRY_TYPE[topic],
8595
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8596
+ data: payload,
8597
+ ...correlationId !== void 0 ? { correlationId } : {}
8598
+ };
8599
+ void enqueueToMatchingSubs(gatewayEvent);
8600
+ };
8601
+ for (const topic of Object.values(TOPICS)) {
8602
+ const fn = makeHandler(topic);
8603
+ bus.on(topic, fn);
8604
+ handlers.push({ topic, fn });
8605
+ }
8606
+ return () => {
8607
+ for (const { topic, fn } of handlers) bus.removeListener(topic, fn);
8608
+ };
8609
+ }
8610
+
8611
+ // src/orchestrator.ts
8612
+ var import_core13 = require("@harness-engineering/core");
8613
+
7132
8614
  // src/logging/logger.ts
7133
8615
  var StructuredLogger = class {
7134
8616
  debug(message, context) {
@@ -7168,8 +8650,8 @@ var StructuredLogger = class {
7168
8650
 
7169
8651
  // src/workspace/config-scanner.ts
7170
8652
  var import_node_fs = require("fs");
7171
- var import_node_path = require("path");
7172
- var import_core9 = require("@harness-engineering/core");
8653
+ var import_node_path4 = require("path");
8654
+ var import_core10 = require("@harness-engineering/core");
7173
8655
  var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
7174
8656
  var BLOCKING_INJECTION_PREFIXES = ["INJ-UNI-", "INJ-REROL-"];
7175
8657
  var DOWNGRADED_SECURITY_RULES = /* @__PURE__ */ new Set(["SEC-AGT-006"]);
@@ -7191,25 +8673,25 @@ async function scanSingleFile(filePath, targetDir, scanner) {
7191
8673
  } catch {
7192
8674
  return null;
7193
8675
  }
7194
- const injectionFindings = (0, import_core9.scanForInjection)(content);
7195
- const findings = (0, import_core9.mapInjectionFindings)(injectionFindings);
8676
+ const injectionFindings = (0, import_core10.scanForInjection)(content);
8677
+ const findings = (0, import_core10.mapInjectionFindings)(injectionFindings);
7196
8678
  const secFindings = await scanner.scanFile(filePath);
7197
- findings.push(...(0, import_core9.mapSecurityFindings)(secFindings, findings));
8679
+ findings.push(...(0, import_core10.mapSecurityFindings)(secFindings, findings));
7198
8680
  const adjusted = adjustFindingSeverity(findings);
7199
8681
  return {
7200
- file: (0, import_node_path.relative)(targetDir, filePath).replaceAll("\\", "/"),
8682
+ file: (0, import_node_path4.relative)(targetDir, filePath).replaceAll("\\", "/"),
7201
8683
  findings: adjusted,
7202
- overallSeverity: (0, import_core9.computeOverallSeverity)(adjusted)
8684
+ overallSeverity: (0, import_core10.computeOverallSeverity)(adjusted)
7203
8685
  };
7204
8686
  }
7205
8687
  async function scanWorkspaceConfig(workspacePath) {
7206
- const scanner = new import_core9.SecurityScanner((0, import_core9.parseSecurityConfig)({}));
8688
+ const scanner = new import_core10.SecurityScanner((0, import_core10.parseSecurityConfig)({}));
7207
8689
  const results = [];
7208
8690
  for (const configFile of CONFIG_FILES) {
7209
- const result = await scanSingleFile((0, import_node_path.join)(workspacePath, configFile), workspacePath, scanner);
8691
+ const result = await scanSingleFile((0, import_node_path4.join)(workspacePath, configFile), workspacePath, scanner);
7210
8692
  if (result) results.push(result);
7211
8693
  }
7212
- return { exitCode: (0, import_core9.computeScanExitCode)(results), results };
8694
+ return { exitCode: (0, import_core10.computeScanExitCode)(results), results };
7213
8695
  }
7214
8696
 
7215
8697
  // src/maintenance/task-registry.ts
@@ -7674,27 +9156,27 @@ var MaintenanceScheduler = class {
7674
9156
  };
7675
9157
 
7676
9158
  // src/maintenance/leader-elector.ts
7677
- var import_types21 = require("@harness-engineering/types");
9159
+ var import_types26 = require("@harness-engineering/types");
7678
9160
  var SingleProcessLeaderElector = class {
7679
9161
  async electLeader() {
7680
- return (0, import_types21.Ok)("claimed");
9162
+ return (0, import_types26.Ok)("claimed");
7681
9163
  }
7682
9164
  };
7683
9165
 
7684
9166
  // src/maintenance/reporter.ts
7685
9167
  var fs14 = __toESM(require("fs"));
7686
9168
  var path15 = __toESM(require("path"));
7687
- var import_zod11 = require("zod");
7688
- var RunResultSchema = import_zod11.z.object({
7689
- taskId: import_zod11.z.string(),
7690
- startedAt: import_zod11.z.string(),
7691
- completedAt: import_zod11.z.string(),
7692
- status: import_zod11.z.enum(["success", "failure", "skipped", "no-issues"]),
7693
- findings: import_zod11.z.number(),
7694
- fixed: import_zod11.z.number(),
7695
- prUrl: import_zod11.z.string().nullable(),
7696
- prUpdated: import_zod11.z.boolean(),
7697
- error: import_zod11.z.string().optional()
9169
+ var import_zod15 = require("zod");
9170
+ var RunResultSchema = import_zod15.z.object({
9171
+ taskId: import_zod15.z.string(),
9172
+ startedAt: import_zod15.z.string(),
9173
+ completedAt: import_zod15.z.string(),
9174
+ status: import_zod15.z.enum(["success", "failure", "skipped", "no-issues"]),
9175
+ findings: import_zod15.z.number(),
9176
+ fixed: import_zod15.z.number(),
9177
+ prUrl: import_zod15.z.string().nullable(),
9178
+ prUpdated: import_zod15.z.boolean(),
9179
+ error: import_zod15.z.string().optional()
7698
9180
  });
7699
9181
  var MAX_HISTORY = 500;
7700
9182
  var fallbackLogger = {
@@ -7721,7 +9203,7 @@ var MaintenanceReporter = class {
7721
9203
  await fs14.promises.mkdir(this.persistDir, { recursive: true });
7722
9204
  const filePath = path15.join(this.persistDir, "history.json");
7723
9205
  const data = await fs14.promises.readFile(filePath, "utf-8");
7724
- const parsed = import_zod11.z.array(RunResultSchema).safeParse(JSON.parse(data));
9206
+ const parsed = import_zod15.z.array(RunResultSchema).safeParse(JSON.parse(data));
7725
9207
  if (parsed.success) {
7726
9208
  this.history = parsed.data.slice(0, MAX_HISTORY);
7727
9209
  }
@@ -8138,6 +9620,25 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8138
9620
  prDetector;
8139
9621
  maintenanceScheduler = null;
8140
9622
  maintenanceReporter = null;
9623
+ // Phase 3 webhooks. `webhookStore` is constructed at server-start and held
9624
+ // only as a local; it's passed into `ServerDependencies` and
9625
+ // `wireWebhookFanout` once and never re-read on `this`. The fan-out
9626
+ // teardown handle is kept on the instance so `stop()` can detach listeners.
9627
+ //
9628
+ // Phase 4 delivery durability: the WebhookQueue (SQLite at
9629
+ // `.harness/webhook-queue.sqlite`) and the WebhookDelivery worker are
9630
+ // retained as instance fields so `stop()` can drain in-flight deliveries
9631
+ // (await worker.stop()) and close the SQLite handle (queue.close()).
9632
+ webhookFanoutOff;
9633
+ webhookQueue;
9634
+ webhookDeliveryWorker;
9635
+ // Phase 5: prompt-cache metrics + OTLP trace export. Both are constructed
9636
+ // unconditionally so non-telemetry call sites can reference them safely; the
9637
+ // OTLPExporter is only handed a fanout subscription when config supplies an
9638
+ // endpoint, and `enabled: false` keeps push() a constant-time no-op.
9639
+ cacheMetrics;
9640
+ otlpExporter;
9641
+ telemetryFanoutOff;
8141
9642
  orchestratorIdPromise;
8142
9643
  recorder;
8143
9644
  intelligenceRunner;
@@ -8172,6 +9673,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8172
9673
  */
8173
9674
  constructor(config, promptTemplate, overrides) {
8174
9675
  super();
9676
+ this.setMaxListeners(50);
8175
9677
  this.config = config;
8176
9678
  this.promptTemplate = promptTemplate;
8177
9679
  this.state = createEmptyState(config);
@@ -8198,7 +9700,8 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8198
9700
  this.renderer = new PromptRenderer();
8199
9701
  this.overrideBackend = overrides?.backend ?? null;
8200
9702
  this.interactionQueue = new InteractionQueue(
8201
- path16.join(config.workspace.root, "..", "interactions")
9703
+ path16.join(config.workspace.root, "..", "interactions"),
9704
+ this
8202
9705
  );
8203
9706
  this.analysisArchive = new AnalysisArchive(path16.join(config.workspace.root, "..", "analyses"));
8204
9707
  const backendsMap = this.config.agent.backends ?? {};
@@ -8214,6 +9717,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8214
9717
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
8215
9718
  }
8216
9719
  }
9720
+ this.cacheMetrics = new import_core13.CacheMetricsRecorder();
8217
9721
  if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
8218
9722
  const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
8219
9723
  const firstBackendName = Object.keys(this.config.agent.backends)[0];
@@ -8226,6 +9730,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8226
9730
  sandboxPolicy,
8227
9731
  ...this.config.agent.container !== void 0 ? { container: this.config.agent.container } : {},
8228
9732
  ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
9733
+ cacheMetrics: this.cacheMetrics,
8229
9734
  getResolverModelFor: (name) => {
8230
9735
  const resolver = this.localResolvers.get(name);
8231
9736
  return resolver ? () => resolver.resolveModel() : void 0;
@@ -8272,8 +9777,47 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8272
9777
  this.intelligenceRunner = new IntelligencePipelineRunner(ctx);
8273
9778
  this.completionHandler = new CompletionHandler(ctx, this.postLifecycleComment.bind(this));
8274
9779
  if (config.server?.port) {
9780
+ const webhookStore = new WebhookStore(
9781
+ path16.join(this.projectRoot, ".harness", "webhooks.json")
9782
+ );
9783
+ this.webhookQueue = new WebhookQueue(
9784
+ path16.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
9785
+ );
9786
+ const webhookDelivery = new WebhookDelivery({
9787
+ queue: this.webhookQueue,
9788
+ store: webhookStore
9789
+ });
9790
+ this.webhookDeliveryWorker = webhookDelivery;
9791
+ this.webhookFanoutOff = wireWebhookFanout({
9792
+ bus: this,
9793
+ store: webhookStore,
9794
+ delivery: webhookDelivery
9795
+ });
9796
+ webhookDelivery.start();
9797
+ const otlpCfg = config.telemetry?.export?.otlp;
9798
+ if (otlpCfg) {
9799
+ this.otlpExporter = new import_core13.OTLPExporter({
9800
+ endpoint: otlpCfg.endpoint,
9801
+ ...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
9802
+ ...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
9803
+ ...otlpCfg.flushIntervalMs !== void 0 ? { flushIntervalMs: otlpCfg.flushIntervalMs } : {},
9804
+ ...otlpCfg.batchSize !== void 0 ? { batchSize: otlpCfg.batchSize } : {}
9805
+ });
9806
+ this.telemetryFanoutOff = wireTelemetryFanout({
9807
+ bus: this,
9808
+ exporter: this.otlpExporter,
9809
+ webhookDelivery,
9810
+ store: webhookStore
9811
+ });
9812
+ }
8275
9813
  this.server = new OrchestratorServer(this, config.server.port, {
8276
9814
  interactionQueue: this.interactionQueue,
9815
+ webhooks: {
9816
+ store: webhookStore,
9817
+ delivery: webhookDelivery,
9818
+ queue: this.webhookQueue
9819
+ },
9820
+ cacheMetrics: this.cacheMetrics,
8277
9821
  plansDir: path16.resolve(config.workspace.root, "..", "docs", "plans"),
8278
9822
  pipeline: this.pipeline,
8279
9823
  analysisArchive: this.analysisArchive,
@@ -8312,7 +9856,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8312
9856
  ...this.config.tracker.apiKey ? { token: this.config.tracker.apiKey } : {},
8313
9857
  ...this.config.tracker.endpoint ? { apiBase: this.config.tracker.endpoint } : {}
8314
9858
  };
8315
- const clientResult = (0, import_core11.createTrackerClient)(trackerCfg);
9859
+ const clientResult = (0, import_core12.createTrackerClient)(trackerCfg);
8316
9860
  if (!clientResult.ok) throw clientResult.error;
8317
9861
  return new GitHubIssuesIssueTrackerAdapter(clientResult.value, this.config.tracker);
8318
9862
  }
@@ -8664,7 +10208,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8664
10208
  { issueId }
8665
10209
  );
8666
10210
  await this.interactionQueue.push({
8667
- id: `interaction-${(0, import_node_crypto7.randomUUID)()}`,
10211
+ id: `interaction-${(0, import_node_crypto15.randomUUID)()}`,
8668
10212
  issueId,
8669
10213
  type: "needs-human",
8670
10214
  reasons: [`Agent pushed branch "${branch}" but did not create a PR. Worktree preserved.`],
@@ -8740,7 +10284,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8740
10284
  { issueId: effect.issueId }
8741
10285
  );
8742
10286
  await this.interactionQueue.push({
8743
- id: `interaction-${(0, import_node_crypto7.randomUUID)()}`,
10287
+ id: `interaction-${(0, import_node_crypto15.randomUUID)()}`,
8744
10288
  issueId: effect.issueId,
8745
10289
  type: "needs-human",
8746
10290
  reasons: effect.reasons,
@@ -8836,12 +10380,12 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8836
10380
  async postLifecycleComment(identifier, externalId, event) {
8837
10381
  try {
8838
10382
  if (!externalId) return;
8839
- const trackerConfig = (0, import_core11.loadTrackerSyncConfig)(this.projectRoot);
10383
+ const trackerConfig = (0, import_core12.loadTrackerSyncConfig)(this.projectRoot);
8840
10384
  if (!trackerConfig) return;
8841
10385
  const token = process.env.GITHUB_TOKEN;
8842
10386
  if (!token) return;
8843
10387
  const orchestratorId = await this.orchestratorIdPromise;
8844
- const adapter = new import_core11.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
10388
+ const adapter = new import_core12.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
8845
10389
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
8846
10390
  const actionMap = {
8847
10391
  claimed: "Dispatching agent for autonomous execution",
@@ -8914,7 +10458,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
8914
10458
  ...f.line !== void 0 ? { line: f.line } : {}
8915
10459
  }))
8916
10460
  );
8917
- (0, import_core10.writeTaint)(
10461
+ (0, import_core11.writeTaint)(
8918
10462
  workspacePath,
8919
10463
  issue.id,
8920
10464
  "Medium-severity injection patterns found in workspace config files",
@@ -9182,6 +10726,9 @@ var Orchestrator = class extends import_node_events.EventEmitter {
9182
10726
  if (this.server) {
9183
10727
  void this.server.start();
9184
10728
  }
10729
+ if (this.otlpExporter) {
10730
+ this.otlpExporter.start();
10731
+ }
9185
10732
  await this.initLocalModelAndPipeline();
9186
10733
  await this.ensureClaimManager();
9187
10734
  const runningIssueIds = new Set(this.state.running.keys());
@@ -9245,6 +10792,26 @@ var Orchestrator = class extends import_node_events.EventEmitter {
9245
10792
  this.maintenanceScheduler.stop();
9246
10793
  this.maintenanceScheduler = null;
9247
10794
  }
10795
+ if (this.webhookFanoutOff) {
10796
+ this.webhookFanoutOff();
10797
+ delete this.webhookFanoutOff;
10798
+ }
10799
+ if (this.telemetryFanoutOff) {
10800
+ this.telemetryFanoutOff();
10801
+ delete this.telemetryFanoutOff;
10802
+ }
10803
+ if (this.otlpExporter) {
10804
+ await this.otlpExporter.stop();
10805
+ delete this.otlpExporter;
10806
+ }
10807
+ if (this.webhookDeliveryWorker) {
10808
+ await this.webhookDeliveryWorker.stop();
10809
+ delete this.webhookDeliveryWorker;
10810
+ }
10811
+ if (this.webhookQueue) {
10812
+ this.webhookQueue.close();
10813
+ delete this.webhookQueue;
10814
+ }
9248
10815
  if (this.server) {
9249
10816
  this.server.stop();
9250
10817
  }
@@ -9666,14 +11233,18 @@ async function syncMain(repoRoot, opts = {}) {
9666
11233
  ClaimManager,
9667
11234
  InteractionQueue,
9668
11235
  LinearGraphQLStub,
11236
+ MAX_ATTEMPTS,
9669
11237
  MockBackend,
9670
11238
  ORCHESTRATOR_IDENTITY_FILE,
9671
11239
  Orchestrator,
9672
11240
  OrchestratorBackendFactory,
9673
11241
  PRDetector,
9674
11242
  PromptRenderer,
11243
+ RETRY_DELAYS_MS,
9675
11244
  RoadmapTrackerAdapter,
9676
11245
  StreamRecorder,
11246
+ TokenStore,
11247
+ WebhookQueue,
9677
11248
  WorkflowLoader,
9678
11249
  WorkspaceHooks,
9679
11250
  WorkspaceManager,