@harness-engineering/orchestrator 0.4.1 → 0.4.3

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