@harness-engineering/orchestrator 0.4.5 → 0.5.0

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
@@ -1870,11 +1870,11 @@ var BackendsMapSchema = z2.record(z2.string(), BackendDefSchema);
1870
1870
  function crossFieldRoutingIssues(backends, routing) {
1871
1871
  const issues = [];
1872
1872
  const names = new Set(Object.keys(backends));
1873
- const checkRef = (path17, name) => {
1873
+ const checkRef = (path19, name) => {
1874
1874
  if (name !== void 0 && !names.has(name)) {
1875
1875
  issues.push({
1876
- path: path17,
1877
- message: `routing.${path17.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1876
+ path: path19,
1877
+ message: `routing.${path19.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1878
1878
  });
1879
1879
  }
1880
1880
  };
@@ -3635,11 +3635,11 @@ function detectLegacyFields(agent) {
3635
3635
  }
3636
3636
  function buildCase1Warnings(presentLegacy, suppressLocalGroup) {
3637
3637
  const warnings = [];
3638
- for (const path17 of presentLegacy) {
3639
- if (CASE1_ALWAYS_SUPPRESS.has(path17)) continue;
3640
- if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path17)) continue;
3638
+ for (const path19 of presentLegacy) {
3639
+ if (CASE1_ALWAYS_SUPPRESS.has(path19)) continue;
3640
+ if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path19)) continue;
3641
3641
  warnings.push(
3642
- `Ignoring legacy field '${path17}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3642
+ `Ignoring legacy field '${path19}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3643
3643
  );
3644
3644
  }
3645
3645
  return warnings;
@@ -3667,7 +3667,7 @@ function migrateAgentConfig(agent) {
3667
3667
  }
3668
3668
  const { backends, routing } = synthesizeBackendsAndRouting(agent);
3669
3669
  const warnings = presentLegacy.map(
3670
- (path17) => `Deprecated config field '${path17}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3670
+ (path19) => `Deprecated config field '${path19}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3671
3671
  );
3672
3672
  return {
3673
3673
  config: { ...agent, backends, routing },
@@ -3756,6 +3756,10 @@ var BackendRouter = class {
3756
3756
  const intel = this.routing.intelligence;
3757
3757
  return intel?.[useCase.layer] ?? this.routing.default;
3758
3758
  }
3759
+ case "isolation": {
3760
+ const iso = this.routing.isolation;
3761
+ return iso?.[useCase.tier] ?? this.routing.default;
3762
+ }
3759
3763
  case "maintenance":
3760
3764
  case "chat":
3761
3765
  return this.routing.default;
@@ -3779,8 +3783,8 @@ var BackendRouter = class {
3779
3783
  validateReferences() {
3780
3784
  const known = new Set(Object.keys(this.backends));
3781
3785
  const missing = [];
3782
- const check = (path17, name) => {
3783
- if (name !== void 0 && !known.has(name)) missing.push({ path: path17, name });
3786
+ const check = (path19, name) => {
3787
+ if (name !== void 0 && !known.has(name)) missing.push({ path: path19, name });
3784
3788
  };
3785
3789
  check("default", this.routing.default);
3786
3790
  check("quick-fix", this.routing["quick-fix"]);
@@ -3789,8 +3793,11 @@ var BackendRouter = class {
3789
3793
  check("diagnostic", this.routing.diagnostic);
3790
3794
  check("intelligence.sel", this.routing.intelligence?.sel);
3791
3795
  check("intelligence.pesl", this.routing.intelligence?.pesl);
3796
+ check("isolation.none", this.routing.isolation?.none);
3797
+ check("isolation.container", this.routing.isolation?.container);
3798
+ check("isolation.remote-sandbox", this.routing.isolation?.["remote-sandbox"]);
3792
3799
  if (missing.length > 0) {
3793
- const detail = missing.map(({ path: path17, name }) => `routing.${path17} -> '${name}'`).join("; ");
3800
+ const detail = missing.map(({ path: path19, name }) => `routing.${path19} -> '${name}'`).join("; ");
3794
3801
  const known_ = [...known].join(", ") || "(none)";
3795
3802
  throw new Error(
3796
3803
  `BackendRouter: routing references unknown backend(s): ${detail}. Defined backends: [${known_}].`
@@ -4950,6 +4957,547 @@ var PiBackend = class {
4950
4957
  }
4951
4958
  };
4952
4959
 
4960
+ // src/agent/backends/ssh.ts
4961
+ import { spawn as spawn3 } from "child_process";
4962
+ import {
4963
+ Ok as Ok16,
4964
+ Err as Err13
4965
+ } from "@harness-engineering/types";
4966
+ var DEFAULT_TIMEOUT_MS2 = 9e4;
4967
+ var FORBIDDEN_HOST_CHARS = /[;&|`$()\n\r<>]/;
4968
+ var SshBackend = class {
4969
+ name = "ssh";
4970
+ config;
4971
+ spawnImpl;
4972
+ constructor(config) {
4973
+ if (!config.host || typeof config.host !== "string") {
4974
+ throw new Error("SshBackend: `host` is required");
4975
+ }
4976
+ if (FORBIDDEN_HOST_CHARS.test(config.host) || config.host.startsWith("-")) {
4977
+ throw new Error(
4978
+ `SshBackend: invalid host '${config.host}' (contains shell metacharacters or starts with '-')`
4979
+ );
4980
+ }
4981
+ if (!config.remoteCommand || typeof config.remoteCommand !== "string") {
4982
+ throw new Error("SshBackend: `remoteCommand` is required");
4983
+ }
4984
+ if (config.user !== void 0 && /[\s;&|`$]/.test(config.user)) {
4985
+ throw new Error(`SshBackend: invalid user '${config.user}'`);
4986
+ }
4987
+ this.config = {
4988
+ host: config.host,
4989
+ remoteCommand: config.remoteCommand,
4990
+ sshBinary: config.sshBinary ?? "ssh",
4991
+ sshOptions: config.sshOptions ?? [],
4992
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS2,
4993
+ ...config.user !== void 0 ? { user: config.user } : {},
4994
+ ...config.port !== void 0 ? { port: config.port } : {},
4995
+ ...config.identityFile !== void 0 ? { identityFile: config.identityFile } : {}
4996
+ };
4997
+ this.spawnImpl = config.spawnImpl ?? spawn3;
4998
+ }
4999
+ /**
5000
+ * Builds the argv passed to the `ssh` binary. Exported as a method on
5001
+ * the class so tests can assert the exact shape without spawning.
5002
+ *
5003
+ * Layout: `[options..., target, '--', remoteCommand]`
5004
+ */
5005
+ buildSshArgs() {
5006
+ const args = [];
5007
+ if (this.config.identityFile) {
5008
+ args.push("-i", this.config.identityFile);
5009
+ }
5010
+ if (this.config.port !== void 0) {
5011
+ args.push("-p", String(this.config.port));
5012
+ }
5013
+ args.push("-o", "BatchMode=yes");
5014
+ for (const opt of this.config.sshOptions) {
5015
+ args.push("-o", opt);
5016
+ }
5017
+ const target = this.config.user ? `${this.config.user}@${this.config.host}` : this.config.host;
5018
+ args.push(target);
5019
+ args.push("--");
5020
+ args.push(this.config.remoteCommand);
5021
+ return args;
5022
+ }
5023
+ async startSession(params) {
5024
+ const session = {
5025
+ sessionId: `ssh-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
5026
+ workspacePath: params.workspacePath,
5027
+ backendName: this.name,
5028
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5029
+ ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
5030
+ };
5031
+ return Ok16(session);
5032
+ }
5033
+ async *runTurn(session, params) {
5034
+ const child = this.spawnImpl(this.config.sshBinary, this.buildSshArgs(), {
5035
+ stdio: ["pipe", "pipe", "pipe"]
5036
+ });
5037
+ const payload = JSON.stringify({
5038
+ kind: "turn",
5039
+ prompt: params.prompt,
5040
+ isContinuation: params.isContinuation,
5041
+ systemPrompt: session.systemPrompt
5042
+ });
5043
+ try {
5044
+ child.stdin.write(payload + "\n");
5045
+ child.stdin.end();
5046
+ } catch (err) {
5047
+ const message = err instanceof Error ? err.message : "failed to write to ssh stdin";
5048
+ try {
5049
+ child.kill("SIGTERM");
5050
+ } catch {
5051
+ }
5052
+ return errResult(session.sessionId, message);
5053
+ }
5054
+ const timeout = setTimeout(() => {
5055
+ try {
5056
+ child.kill("SIGTERM");
5057
+ } catch {
5058
+ }
5059
+ }, this.config.timeoutMs);
5060
+ let finalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
5061
+ let success = true;
5062
+ let lastError;
5063
+ try {
5064
+ for await (const line of readLines(child.stdout)) {
5065
+ let event;
5066
+ try {
5067
+ event = parseEvent(line, session.sessionId);
5068
+ } catch (err) {
5069
+ const message = err instanceof Error ? err.message : "unparseable ssh event";
5070
+ success = false;
5071
+ lastError = message;
5072
+ break;
5073
+ }
5074
+ if (!event) continue;
5075
+ if (event.usage) finalUsage = event.usage;
5076
+ if (event.type === "error" && typeof event.content === "string") {
5077
+ lastError = event.content;
5078
+ success = false;
5079
+ }
5080
+ yield event;
5081
+ }
5082
+ const exitCode = await waitForExit(child);
5083
+ if (exitCode !== 0 && exitCode !== null) {
5084
+ success = false;
5085
+ lastError = lastError ?? `ssh exited with code ${exitCode}`;
5086
+ }
5087
+ } finally {
5088
+ clearTimeout(timeout);
5089
+ }
5090
+ return {
5091
+ success,
5092
+ sessionId: session.sessionId,
5093
+ usage: finalUsage,
5094
+ ...lastError !== void 0 ? { error: lastError } : {}
5095
+ };
5096
+ }
5097
+ async stopSession(_session) {
5098
+ return Ok16(void 0);
5099
+ }
5100
+ async healthCheck() {
5101
+ const args = [...this.buildSshArgs()];
5102
+ args[args.length - 1] = "true";
5103
+ return new Promise((resolve6) => {
5104
+ let child;
5105
+ try {
5106
+ child = this.spawnImpl(this.config.sshBinary, args, {
5107
+ stdio: ["ignore", "ignore", "pipe"]
5108
+ });
5109
+ } catch (err) {
5110
+ resolve6(
5111
+ Err13({
5112
+ category: "agent_not_found",
5113
+ message: err instanceof Error ? err.message : "failed to spawn ssh"
5114
+ })
5115
+ );
5116
+ return;
5117
+ }
5118
+ let stderr = "";
5119
+ child.stderr?.on("data", (chunk) => {
5120
+ stderr += chunk.toString();
5121
+ });
5122
+ const timer = setTimeout(() => {
5123
+ try {
5124
+ child.kill("SIGTERM");
5125
+ } catch {
5126
+ }
5127
+ }, this.config.timeoutMs);
5128
+ child.on("close", (code) => {
5129
+ clearTimeout(timer);
5130
+ if (code === 0) {
5131
+ resolve6(Ok16(void 0));
5132
+ } else {
5133
+ resolve6(
5134
+ Err13({
5135
+ category: "agent_not_found",
5136
+ message: `ssh health check failed (exit=${code ?? "null"}): ${stderr.slice(0, 500)}`
5137
+ })
5138
+ );
5139
+ }
5140
+ });
5141
+ child.on("error", (err) => {
5142
+ clearTimeout(timer);
5143
+ resolve6(Err13({ category: "agent_not_found", message: err.message }));
5144
+ });
5145
+ });
5146
+ }
5147
+ };
5148
+ function errResult(sessionId, message) {
5149
+ return {
5150
+ success: false,
5151
+ sessionId,
5152
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5153
+ error: message
5154
+ };
5155
+ }
5156
+ function parseEvent(line, sessionId) {
5157
+ const trimmed = line.trim();
5158
+ if (trimmed.length === 0) return null;
5159
+ const raw = JSON.parse(trimmed);
5160
+ if (typeof raw.type !== "string") {
5161
+ throw new Error(`ssh event missing 'type': ${trimmed.slice(0, 200)}`);
5162
+ }
5163
+ const ev = {
5164
+ type: raw.type,
5165
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
5166
+ sessionId
5167
+ };
5168
+ if (typeof raw.subtype === "string") ev.subtype = raw.subtype;
5169
+ if (raw.content !== void 0) ev.content = raw.content;
5170
+ if (isUsage(raw.usage)) ev.usage = raw.usage;
5171
+ return ev;
5172
+ }
5173
+ function isUsage(u) {
5174
+ if (!u || typeof u !== "object") return false;
5175
+ const o = u;
5176
+ return typeof o.inputTokens === "number" && typeof o.outputTokens === "number" && typeof o.totalTokens === "number";
5177
+ }
5178
+ async function* readLines(stream) {
5179
+ let buffer = "";
5180
+ for await (const chunk of stream) {
5181
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5182
+ let idx;
5183
+ while ((idx = buffer.indexOf("\n")) >= 0) {
5184
+ yield buffer.slice(0, idx);
5185
+ buffer = buffer.slice(idx + 1);
5186
+ }
5187
+ }
5188
+ if (buffer.length > 0) yield buffer;
5189
+ }
5190
+ function waitForExit(child) {
5191
+ return new Promise((resolve6) => {
5192
+ if (child.exitCode !== null) {
5193
+ resolve6(child.exitCode);
5194
+ return;
5195
+ }
5196
+ child.once("close", (code) => resolve6(code));
5197
+ child.once("error", () => resolve6(null));
5198
+ });
5199
+ }
5200
+
5201
+ // src/agent/backends/serverless.ts
5202
+ import { spawn as spawn4 } from "child_process";
5203
+ import {
5204
+ Ok as Ok17,
5205
+ Err as Err14
5206
+ } from "@harness-engineering/types";
5207
+ var ServerlessBackend = class {
5208
+ handles = /* @__PURE__ */ new Map();
5209
+ async startSession(params) {
5210
+ const start = await this.coldStart(params);
5211
+ if (!start.ok) return start;
5212
+ const session = {
5213
+ sessionId: `${this.name}-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
5214
+ workspacePath: params.workspacePath,
5215
+ backendName: this.name,
5216
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5217
+ };
5218
+ this.handles.set(session.sessionId, start.value);
5219
+ return Ok17(session);
5220
+ }
5221
+ async *runTurn(session, params) {
5222
+ const handle = this.handles.get(session.sessionId);
5223
+ if (!handle) {
5224
+ return {
5225
+ success: false,
5226
+ sessionId: session.sessionId,
5227
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5228
+ error: `no serverless handle for session ${session.sessionId}`
5229
+ };
5230
+ }
5231
+ return yield* this.runOnHandle(handle, params, session);
5232
+ }
5233
+ async stopSession(session) {
5234
+ const handle = this.handles.get(session.sessionId);
5235
+ if (!handle) return Ok17(void 0);
5236
+ this.handles.delete(session.sessionId);
5237
+ return this.teardown(handle);
5238
+ }
5239
+ };
5240
+ var FORBIDDEN_IMAGE_CHARS = /[;&|`$()\n\r<>]/;
5241
+ var BLOCKED_DOCKER_FLAGS = [
5242
+ "--privileged",
5243
+ "--cap-add",
5244
+ "--security-opt",
5245
+ "--pid",
5246
+ "--ipc",
5247
+ "--userns"
5248
+ ];
5249
+ var DEFAULT_OCI_TIMEOUT_MS = 9e4;
5250
+ var OciServerlessBackend = class extends ServerlessBackend {
5251
+ name = "serverless:oci";
5252
+ config;
5253
+ spawnImpl;
5254
+ envSource;
5255
+ constructor(config) {
5256
+ super();
5257
+ if (!config.image || typeof config.image !== "string") {
5258
+ throw new Error("OciServerlessBackend: `image` is required");
5259
+ }
5260
+ if (FORBIDDEN_IMAGE_CHARS.test(config.image) || config.image.startsWith("-")) {
5261
+ throw new Error(
5262
+ `OciServerlessBackend: invalid image '${config.image}' (contains shell metacharacters or starts with '-')`
5263
+ );
5264
+ }
5265
+ this.config = {
5266
+ image: config.image,
5267
+ pullPolicy: config.pullPolicy ?? "if-not-present",
5268
+ runtime: config.runtime ?? "docker",
5269
+ envPassthrough: config.envPassthrough ?? [],
5270
+ timeoutMs: config.timeoutMs ?? DEFAULT_OCI_TIMEOUT_MS,
5271
+ extraArgs: sanitizeExtraArgs(config.extraArgs),
5272
+ ...config.registry !== void 0 ? { registry: config.registry } : {}
5273
+ };
5274
+ this.spawnImpl = config.spawnImpl ?? spawn4;
5275
+ this.envSource = config.envSource ?? process.env;
5276
+ }
5277
+ /** Builds the argv for `docker run -d ...`. Exposed for tests. */
5278
+ buildRunArgs() {
5279
+ const env = this.collectEnv();
5280
+ const args = ["run", "-d", "--rm"];
5281
+ for (const [k, v] of Object.entries(env)) {
5282
+ args.push("-e", `${k}=${v}`);
5283
+ }
5284
+ for (const ea of this.config.extraArgs) {
5285
+ args.push(ea);
5286
+ }
5287
+ args.push("--");
5288
+ args.push(this.config.image);
5289
+ return args;
5290
+ }
5291
+ /** Builds the argv for `docker exec <id> -- agent`. Exposed for tests. */
5292
+ buildExecArgs(handleId) {
5293
+ return ["exec", "-i", handleId, "/agent"];
5294
+ }
5295
+ async coldStart(_params) {
5296
+ if (this.config.pullPolicy === "always") {
5297
+ const pull = await this.runOneShot(this.config.runtime, ["pull", this.config.image]);
5298
+ if (!pull.ok) return pull;
5299
+ }
5300
+ const result = await this.runOneShot(this.config.runtime, this.buildRunArgs());
5301
+ if (!result.ok) return result;
5302
+ const id = result.value.trim().split(/\s+/)[0] ?? "";
5303
+ if (!id) {
5304
+ return Err14({
5305
+ category: "response_error",
5306
+ message: "OciServerlessBackend: empty container id from runtime"
5307
+ });
5308
+ }
5309
+ return Ok17({ id, adapter: this.name });
5310
+ }
5311
+ async *runOnHandle(handle, params, session) {
5312
+ const child = this.spawnImpl(this.config.runtime, this.buildExecArgs(handle.id), {
5313
+ stdio: ["pipe", "pipe", "pipe"]
5314
+ });
5315
+ const payload = JSON.stringify({
5316
+ kind: "turn",
5317
+ prompt: params.prompt,
5318
+ isContinuation: params.isContinuation
5319
+ });
5320
+ try {
5321
+ child.stdin.write(payload + "\n");
5322
+ child.stdin.end();
5323
+ } catch (err) {
5324
+ const message = err instanceof Error ? err.message : "failed to write to docker stdin";
5325
+ return turnFailure(session.sessionId, message);
5326
+ }
5327
+ const timeout = setTimeout(() => {
5328
+ try {
5329
+ child.kill("SIGTERM");
5330
+ } catch {
5331
+ }
5332
+ }, this.config.timeoutMs);
5333
+ let finalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
5334
+ let success = true;
5335
+ let lastError;
5336
+ try {
5337
+ for await (const line of readLines2(child.stdout)) {
5338
+ const ev = tryParseEvent(line, session.sessionId);
5339
+ if (!ev) continue;
5340
+ if (ev.usage) finalUsage = ev.usage;
5341
+ if (ev.type === "error" && typeof ev.content === "string") {
5342
+ success = false;
5343
+ lastError = ev.content;
5344
+ }
5345
+ yield ev;
5346
+ }
5347
+ const code = await waitForExit2(child);
5348
+ if (code !== 0 && code !== null) {
5349
+ success = false;
5350
+ lastError = lastError ?? `runtime exec exited with code ${code}`;
5351
+ }
5352
+ } finally {
5353
+ clearTimeout(timeout);
5354
+ }
5355
+ return {
5356
+ success,
5357
+ sessionId: session.sessionId,
5358
+ usage: finalUsage,
5359
+ ...lastError !== void 0 ? { error: lastError } : {}
5360
+ };
5361
+ }
5362
+ async teardown(handle) {
5363
+ if (handle.adapter !== this.name) {
5364
+ return Err14({
5365
+ category: "response_error",
5366
+ message: `handle adapter mismatch: got '${handle.adapter}', expected '${this.name}'`
5367
+ });
5368
+ }
5369
+ const stop = await this.runOneShot(this.config.runtime, ["stop", handle.id]);
5370
+ if (!stop.ok) return stop;
5371
+ return Ok17(void 0);
5372
+ }
5373
+ async healthCheck() {
5374
+ return mapOk(
5375
+ await this.runOneShot(this.config.runtime, ["version", "--format", "{{.Server.Version}}"])
5376
+ );
5377
+ }
5378
+ collectEnv() {
5379
+ const out = {};
5380
+ for (const key of this.config.envPassthrough) {
5381
+ const val = this.envSource[key];
5382
+ if (typeof val === "string") out[key] = val;
5383
+ }
5384
+ return out;
5385
+ }
5386
+ runOneShot(binary, args) {
5387
+ return new Promise((resolve6) => {
5388
+ let child;
5389
+ try {
5390
+ child = this.spawnImpl(binary, args, {
5391
+ stdio: ["ignore", "pipe", "pipe"]
5392
+ });
5393
+ } catch (err) {
5394
+ resolve6(
5395
+ Err14({
5396
+ category: "agent_not_found",
5397
+ message: err instanceof Error ? err.message : "failed to spawn runtime"
5398
+ })
5399
+ );
5400
+ return;
5401
+ }
5402
+ let stdout = "";
5403
+ let stderr = "";
5404
+ child.stdout?.on("data", (chunk) => {
5405
+ stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5406
+ });
5407
+ child.stderr?.on("data", (chunk) => {
5408
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5409
+ });
5410
+ const timer = setTimeout(() => {
5411
+ try {
5412
+ child.kill("SIGTERM");
5413
+ } catch {
5414
+ }
5415
+ }, this.config.timeoutMs);
5416
+ child.on("close", (code) => {
5417
+ clearTimeout(timer);
5418
+ if (code === 0) {
5419
+ resolve6(Ok17(stdout));
5420
+ } else {
5421
+ resolve6(
5422
+ Err14({
5423
+ category: "response_error",
5424
+ message: `runtime '${binary} ${args.join(" ")}' exited ${code ?? "null"}: ${stderr.slice(0, 500)}`
5425
+ })
5426
+ );
5427
+ }
5428
+ });
5429
+ child.on("error", (err) => {
5430
+ clearTimeout(timer);
5431
+ resolve6(Err14({ category: "agent_not_found", message: err.message }));
5432
+ });
5433
+ });
5434
+ }
5435
+ };
5436
+ function sanitizeExtraArgs(extraArgs) {
5437
+ if (!extraArgs) return [];
5438
+ return extraArgs.filter((arg) => !BLOCKED_DOCKER_FLAGS.some((flag) => arg.startsWith(flag)));
5439
+ }
5440
+ function mapOk(r) {
5441
+ return r.ok ? Ok17(void 0) : r;
5442
+ }
5443
+ function turnFailure(sessionId, message) {
5444
+ return {
5445
+ success: false,
5446
+ sessionId,
5447
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5448
+ error: message
5449
+ };
5450
+ }
5451
+ function tryParseEvent(line, sessionId) {
5452
+ const trimmed = line.trim();
5453
+ if (!trimmed) return null;
5454
+ let raw;
5455
+ try {
5456
+ raw = JSON.parse(trimmed);
5457
+ } catch {
5458
+ return null;
5459
+ }
5460
+ if (!raw || typeof raw !== "object") return null;
5461
+ const o = raw;
5462
+ if (typeof o.type !== "string") return null;
5463
+ const ev = {
5464
+ type: o.type,
5465
+ timestamp: typeof o.timestamp === "string" ? o.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
5466
+ sessionId
5467
+ };
5468
+ if (typeof o.subtype === "string") ev.subtype = o.subtype;
5469
+ if (o.content !== void 0) ev.content = o.content;
5470
+ if (isUsage2(o.usage)) ev.usage = o.usage;
5471
+ return ev;
5472
+ }
5473
+ function isUsage2(u) {
5474
+ if (!u || typeof u !== "object") return false;
5475
+ const o = u;
5476
+ return typeof o.inputTokens === "number" && typeof o.outputTokens === "number" && typeof o.totalTokens === "number";
5477
+ }
5478
+ async function* readLines2(stream) {
5479
+ let buffer = "";
5480
+ for await (const chunk of stream) {
5481
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5482
+ let idx;
5483
+ while ((idx = buffer.indexOf("\n")) >= 0) {
5484
+ yield buffer.slice(0, idx);
5485
+ buffer = buffer.slice(idx + 1);
5486
+ }
5487
+ }
5488
+ if (buffer.length > 0) yield buffer;
5489
+ }
5490
+ function waitForExit2(child) {
5491
+ return new Promise((resolve6) => {
5492
+ if (child.exitCode !== null) {
5493
+ resolve6(child.exitCode);
5494
+ return;
5495
+ }
5496
+ child.once("close", (code) => resolve6(code));
5497
+ child.once("error", () => resolve6(null));
5498
+ });
5499
+ }
5500
+
4953
5501
  // src/agent/backend-factory.ts
4954
5502
  function makeGetModel(model) {
4955
5503
  if (typeof model === "string") return () => model;
@@ -4999,6 +5547,35 @@ function createBackend(def, options = {}) {
4999
5547
  ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
5000
5548
  });
5001
5549
  }
5550
+ case "ssh": {
5551
+ return new SshBackend({
5552
+ host: def.host,
5553
+ remoteCommand: def.remoteCommand,
5554
+ ...def.user !== void 0 ? { user: def.user } : {},
5555
+ ...def.port !== void 0 ? { port: def.port } : {},
5556
+ ...def.identityFile !== void 0 ? { identityFile: def.identityFile } : {},
5557
+ ...def.sshOptions !== void 0 ? { sshOptions: def.sshOptions } : {},
5558
+ ...def.sshBinary !== void 0 ? { sshBinary: def.sshBinary } : {}
5559
+ });
5560
+ }
5561
+ case "serverless": {
5562
+ switch (def.adapter) {
5563
+ case "oci":
5564
+ return new OciServerlessBackend({
5565
+ image: def.image,
5566
+ ...def.registry !== void 0 ? { registry: def.registry } : {},
5567
+ ...def.pullPolicy !== void 0 ? { pullPolicy: def.pullPolicy } : {},
5568
+ ...def.envPassthrough !== void 0 ? { envPassthrough: def.envPassthrough } : {},
5569
+ ...def.runtime !== void 0 ? { runtime: def.runtime } : {}
5570
+ });
5571
+ default: {
5572
+ const exhaustive = def.adapter;
5573
+ throw new Error(
5574
+ `createBackend: unknown serverless adapter ${JSON.stringify(exhaustive)}`
5575
+ );
5576
+ }
5577
+ }
5578
+ }
5002
5579
  default: {
5003
5580
  const exhaustive = def;
5004
5581
  throw new Error(`createBackend: unknown backend type ${JSON.stringify(exhaustive)}`);
@@ -5008,13 +5585,13 @@ function createBackend(def, options = {}) {
5008
5585
 
5009
5586
  // src/agent/backends/container.ts
5010
5587
  import {
5011
- Err as Err13
5588
+ Err as Err15
5012
5589
  } from "@harness-engineering/types";
5013
5590
  function toAgentError(message, details) {
5014
5591
  return { category: "response_error", message, details };
5015
5592
  }
5016
5593
  var BLOCKED_FLAGS = ["--privileged", "--cap-add", "--security-opt", "--pid", "--ipc", "--userns"];
5017
- function sanitizeExtraArgs(extraArgs) {
5594
+ function sanitizeExtraArgs2(extraArgs) {
5018
5595
  if (!extraArgs) return [];
5019
5596
  return extraArgs.filter((arg) => !BLOCKED_FLAGS.some((flag) => arg.startsWith(flag)));
5020
5597
  }
@@ -5040,7 +5617,7 @@ var ContainerBackend = class {
5040
5617
  }
5041
5618
  const result = await this.secretBackend.resolveSecrets(this.secretKeys);
5042
5619
  if (!result.ok) {
5043
- return Err13(toAgentError(`Secret resolution failed: ${result.error.message}`, result.error));
5620
+ return Err15(toAgentError(`Secret resolution failed: ${result.error.message}`, result.error));
5044
5621
  }
5045
5622
  return { ok: true, value: result.value };
5046
5623
  }
@@ -5053,7 +5630,7 @@ var ContainerBackend = class {
5053
5630
  network: this.containerConfig.network ?? "none",
5054
5631
  env
5055
5632
  };
5056
- const sanitized = sanitizeExtraArgs(this.containerConfig.extraArgs);
5633
+ const sanitized = sanitizeExtraArgs2(this.containerConfig.extraArgs);
5057
5634
  if (sanitized.length > 0) {
5058
5635
  opts.extraArgs = sanitized;
5059
5636
  }
@@ -5065,7 +5642,7 @@ var ContainerBackend = class {
5065
5642
  const createOpts = this.buildCreateOpts(params, envResult.value);
5066
5643
  const containerResult = await this.runtime.createContainer(createOpts);
5067
5644
  if (!containerResult.ok) {
5068
- return Err13(
5645
+ return Err15(
5069
5646
  toAgentError(
5070
5647
  `Container creation failed: ${containerResult.error.message}`,
5071
5648
  containerResult.error
@@ -5090,7 +5667,7 @@ var ContainerBackend = class {
5090
5667
  this.containerHandles.delete(session.sessionId);
5091
5668
  const removeResult = await this.runtime.removeContainer(handle);
5092
5669
  if (!removeResult.ok) {
5093
- return Err13(
5670
+ return Err15(
5094
5671
  toAgentError(
5095
5672
  `Container removal failed: ${removeResult.error.message}`,
5096
5673
  removeResult.error
@@ -5103,7 +5680,7 @@ var ContainerBackend = class {
5103
5680
  async healthCheck() {
5104
5681
  const runtimeResult = await this.runtime.healthCheck();
5105
5682
  if (!runtimeResult.ok) {
5106
- return Err13({
5683
+ return Err15({
5107
5684
  category: "agent_not_found",
5108
5685
  message: `Container runtime unhealthy: ${runtimeResult.error.message}`,
5109
5686
  details: runtimeResult.error
@@ -5114,8 +5691,8 @@ var ContainerBackend = class {
5114
5691
  };
5115
5692
 
5116
5693
  // src/agent/runtime/docker.ts
5117
- import { execFile as execFile3, spawn as spawn3 } from "child_process";
5118
- import { Ok as Ok16, Err as Err14 } from "@harness-engineering/types";
5694
+ import { execFile as execFile3, spawn as spawn5 } from "child_process";
5695
+ import { Ok as Ok18, Err as Err16 } from "@harness-engineering/types";
5119
5696
  function dockerExec(args) {
5120
5697
  return new Promise((resolve6, reject) => {
5121
5698
  execFile3("docker", args, (error, stdout) => {
@@ -5148,9 +5725,9 @@ var DockerRuntime = class {
5148
5725
  args.push(opts.image);
5149
5726
  args.push("sleep", "infinity");
5150
5727
  const containerId = await dockerExec(args);
5151
- return Ok16({ containerId, runtime: this.name });
5728
+ return Ok18({ containerId, runtime: this.name });
5152
5729
  } catch (error) {
5153
- return Err14({
5730
+ return Err16({
5154
5731
  category: "container_create_failed",
5155
5732
  message: `Failed to create container: ${error instanceof Error ? error.message : String(error)}`,
5156
5733
  details: error
@@ -5172,7 +5749,7 @@ var DockerRuntime = class {
5172
5749
  }
5173
5750
  }
5174
5751
  execArgs.push(handle.containerId, ...cmd);
5175
- const child = spawn3("docker", execArgs);
5752
+ const child = spawn5("docker", execArgs);
5176
5753
  const readline3 = await import("readline");
5177
5754
  const rl = readline3.createInterface({ input: child.stdout, terminal: false });
5178
5755
  try {
@@ -5194,9 +5771,9 @@ var DockerRuntime = class {
5194
5771
  async removeContainer(handle) {
5195
5772
  try {
5196
5773
  await dockerExec(["rm", "-f", handle.containerId]);
5197
- return Ok16(void 0);
5774
+ return Ok18(void 0);
5198
5775
  } catch (error) {
5199
- return Err14({
5776
+ return Err16({
5200
5777
  category: "container_remove_failed",
5201
5778
  message: `Failed to remove container: ${error instanceof Error ? error.message : String(error)}`,
5202
5779
  details: error
@@ -5206,9 +5783,9 @@ var DockerRuntime = class {
5206
5783
  async healthCheck() {
5207
5784
  try {
5208
5785
  await dockerExec(["info", "--format", "{{.ServerVersion}}"]);
5209
- return Ok16(void 0);
5786
+ return Ok18(void 0);
5210
5787
  } catch (error) {
5211
- return Err14({
5788
+ return Err16({
5212
5789
  category: "runtime_not_found",
5213
5790
  message: `Docker is not available: ${error instanceof Error ? error.message : String(error)}`,
5214
5791
  details: error
@@ -5218,7 +5795,7 @@ var DockerRuntime = class {
5218
5795
  };
5219
5796
 
5220
5797
  // src/agent/secrets/env.ts
5221
- import { Ok as Ok17, Err as Err15 } from "@harness-engineering/types";
5798
+ import { Ok as Ok19, Err as Err17 } from "@harness-engineering/types";
5222
5799
  var EnvSecretBackend = class {
5223
5800
  name = "env";
5224
5801
  async resolveSecrets(keys) {
@@ -5226,7 +5803,7 @@ var EnvSecretBackend = class {
5226
5803
  for (const key of keys) {
5227
5804
  const value = process.env[key];
5228
5805
  if (value === void 0) {
5229
- return Err15({
5806
+ return Err17({
5230
5807
  category: "secret_not_found",
5231
5808
  message: `Environment variable '${key}' is not set`,
5232
5809
  key
@@ -5234,16 +5811,16 @@ var EnvSecretBackend = class {
5234
5811
  }
5235
5812
  secrets[key] = value;
5236
5813
  }
5237
- return Ok17(secrets);
5814
+ return Ok19(secrets);
5238
5815
  }
5239
5816
  async healthCheck() {
5240
- return Ok17(void 0);
5817
+ return Ok19(void 0);
5241
5818
  }
5242
5819
  };
5243
5820
 
5244
5821
  // src/agent/secrets/onepassword.ts
5245
5822
  import { execFile as execFile4 } from "child_process";
5246
- import { Ok as Ok18, Err as Err16 } from "@harness-engineering/types";
5823
+ import { Ok as Ok20, Err as Err18 } from "@harness-engineering/types";
5247
5824
  function opExec(args) {
5248
5825
  return new Promise((resolve6, reject) => {
5249
5826
  execFile4("op", args, (error, stdout) => {
@@ -5268,21 +5845,21 @@ var OnePasswordSecretBackend = class {
5268
5845
  const value = await opExec(["read", `op://${this.vault}/${key}/password`]);
5269
5846
  secrets[key] = value;
5270
5847
  } catch (error) {
5271
- return Err16({
5848
+ return Err18({
5272
5849
  category: "access_denied",
5273
5850
  message: `Failed to read secret '${key}' from 1Password: ${error instanceof Error ? error.message : String(error)}`,
5274
5851
  key
5275
5852
  });
5276
5853
  }
5277
5854
  }
5278
- return Ok18(secrets);
5855
+ return Ok20(secrets);
5279
5856
  }
5280
5857
  async healthCheck() {
5281
5858
  try {
5282
5859
  await opExec(["--version"]);
5283
- return Ok18(void 0);
5860
+ return Ok20(void 0);
5284
5861
  } catch (error) {
5285
- return Err16({
5862
+ return Err18({
5286
5863
  category: "provider_unavailable",
5287
5864
  message: `1Password CLI is not available: ${error instanceof Error ? error.message : String(error)}`
5288
5865
  });
@@ -5292,7 +5869,7 @@ var OnePasswordSecretBackend = class {
5292
5869
 
5293
5870
  // src/agent/secrets/vault.ts
5294
5871
  import { execFile as execFile5 } from "child_process";
5295
- import { Ok as Ok19, Err as Err17 } from "@harness-engineering/types";
5872
+ import { Ok as Ok21, Err as Err19 } from "@harness-engineering/types";
5296
5873
  function vaultExec(args, env) {
5297
5874
  return new Promise((resolve6, reject) => {
5298
5875
  execFile5("vault", args, { env: { ...process.env, ...env } }, (error, stdout) => {
@@ -5323,11 +5900,11 @@ var VaultSecretBackend = class {
5323
5900
  } catch (error) {
5324
5901
  const msg = error instanceof Error ? error.message : String(error);
5325
5902
  const category = error instanceof SyntaxError ? "access_denied" : "access_denied";
5326
- return Err17({ category, message: `Failed to read from Vault: ${msg}` });
5903
+ return Err19({ category, message: `Failed to read from Vault: ${msg}` });
5327
5904
  }
5328
5905
  const missing = keys.find((k) => !(k in data));
5329
5906
  if (missing) {
5330
- return Err17({
5907
+ return Err19({
5331
5908
  category: "secret_not_found",
5332
5909
  message: `Secret key '${missing}' not found in Vault path '${this.path}'`,
5333
5910
  key: missing
@@ -5335,14 +5912,14 @@ var VaultSecretBackend = class {
5335
5912
  }
5336
5913
  const secrets = {};
5337
5914
  for (const key of keys) secrets[key] = data[key];
5338
- return Ok19(secrets);
5915
+ return Ok21(secrets);
5339
5916
  }
5340
5917
  async healthCheck() {
5341
5918
  try {
5342
5919
  await vaultExec(["version"]);
5343
- return Ok19(void 0);
5920
+ return Ok21(void 0);
5344
5921
  } catch (error) {
5345
- return Err17({
5922
+ return Err19({
5346
5923
  category: "provider_unavailable",
5347
5924
  message: `Vault CLI is not available: ${error instanceof Error ? error.message : String(error)}`
5348
5925
  });
@@ -5488,6 +6065,8 @@ function buildAnalysisProvider(args) {
5488
6065
  return buildClaudeCliProvider(def, args, layerModel);
5489
6066
  case "mock":
5490
6067
  case "gemini":
6068
+ case "ssh":
6069
+ case "serverless":
5491
6070
  logger.warn(
5492
6071
  `Intelligence pipeline disabled for layer '${layer}': routed backend '${backendName}' has type '${def.type}' which has no AnalysisProvider implementation.`
5493
6072
  );
@@ -5911,7 +6490,7 @@ function handlePlansRoute(req, res, plansDir) {
5911
6490
  }
5912
6491
 
5913
6492
  // src/server/routes/chat-proxy.ts
5914
- import { spawn as spawn4 } from "child_process";
6493
+ import { spawn as spawn6 } from "child_process";
5915
6494
  import { randomUUID as randomUUID4 } from "crypto";
5916
6495
  import * as readline2 from "readline";
5917
6496
  import { z as z6 } from "zod";
@@ -5997,7 +6576,7 @@ async function handleChatRequest(req, res, command) {
5997
6576
  });
5998
6577
  emit(res, { type: "session", sessionId });
5999
6578
  const args = buildArgs(parsed.prompt, sessionId, isFirstTurn, parsed.system);
6000
- child = spawn4(command, args, { env: buildChildEnv(), stdio: "pipe" });
6579
+ child = spawn6(command, args, { env: buildChildEnv(), stdio: "pipe" });
6001
6580
  child.stdin?.end();
6002
6581
  let clientDisconnected = false;
6003
6582
  res.on("close", () => {
@@ -7362,8 +7941,8 @@ function parseToken(raw) {
7362
7941
  return { id: raw.slice(0, dot), secret: raw.slice(dot + 1) };
7363
7942
  }
7364
7943
  var TokenStore = class {
7365
- constructor(path17) {
7366
- this.path = path17;
7944
+ constructor(path19) {
7945
+ this.path = path19;
7367
7946
  }
7368
7947
  path;
7369
7948
  cache = null;
@@ -7470,8 +8049,8 @@ import { appendFile, mkdir as mkdir8 } from "fs/promises";
7470
8049
  import { dirname as dirname5 } from "path";
7471
8050
  import { AuthAuditEntrySchema } from "@harness-engineering/types";
7472
8051
  var AuditLogger = class {
7473
- constructor(path17, opts = {}) {
7474
- this.path = path17;
8052
+ constructor(path19, opts = {}) {
8053
+ this.path = path19;
7475
8054
  this.opts = opts;
7476
8055
  }
7477
8056
  path;
@@ -7566,9 +8145,9 @@ var V1_BRIDGE_ROUTES = [
7566
8145
  function isV1Bridge(method, url) {
7567
8146
  return V1_BRIDGE_ROUTES.some((r) => r.method === method && r.pattern.test(url));
7568
8147
  }
7569
- function requiredBridgeScope(method, path17) {
8148
+ function requiredBridgeScope(method, path19) {
7570
8149
  for (const r of V1_BRIDGE_ROUTES) {
7571
- if (r.method === method && r.pattern.test(path17)) return r.scope;
8150
+ if (r.method === method && r.pattern.test(path19)) return r.scope;
7572
8151
  }
7573
8152
  return null;
7574
8153
  }
@@ -7578,24 +8157,24 @@ function hasScope(held, required) {
7578
8157
  if (held.includes("admin")) return true;
7579
8158
  return held.includes(required);
7580
8159
  }
7581
- function requiredScopeForRoute(method, path17) {
7582
- const bridgeScope = requiredBridgeScope(method, path17);
8160
+ function requiredScopeForRoute(method, path19) {
8161
+ const bridgeScope = requiredBridgeScope(method, path19);
7583
8162
  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"))
8163
+ if (path19 === "/api/v1/auth/token" && method === "POST") return "admin";
8164
+ if (path19 === "/api/v1/auth/tokens" && method === "GET") return "admin";
8165
+ if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path19) && method === "DELETE") return "admin";
8166
+ if ((path19 === "/api/state" || path19 === "/api/v1/state") && method === "GET") return "read-status";
8167
+ if (path19.startsWith("/api/interactions")) return "resolve-interaction";
8168
+ if (path19.startsWith("/api/plans")) return "read-status";
8169
+ if (path19.startsWith("/api/analyze") || path19.startsWith("/api/analyses")) return "read-status";
8170
+ if (path19.startsWith("/api/roadmap-actions")) return "modify-roadmap";
8171
+ if (path19.startsWith("/api/dispatch-actions")) return "trigger-job";
8172
+ if (path19.startsWith("/api/local-model") || path19.startsWith("/api/local-models"))
7594
8173
  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";
8174
+ if (path19.startsWith("/api/maintenance")) return "trigger-job";
8175
+ if (path19.startsWith("/api/streams")) return "read-status";
8176
+ if (path19.startsWith("/api/sessions")) return "read-status";
8177
+ if (path19.startsWith("/api/chat-proxy")) return "trigger-job";
7599
8178
  return null;
7600
8179
  }
7601
8180
 
@@ -7999,8 +8578,8 @@ function genSecret2() {
7999
8578
  return randomBytes4(32).toString("base64url");
8000
8579
  }
8001
8580
  var WebhookStore = class {
8002
- constructor(path17) {
8003
- this.path = path17;
8581
+ constructor(path19) {
8582
+ this.path = path19;
8004
8583
  }
8005
8584
  path;
8006
8585
  cache = null;
@@ -8586,6 +9165,335 @@ function wireTelemetryFanout(params) {
8586
9165
  };
8587
9166
  }
8588
9167
 
9168
+ // src/notifications/slack-sink.ts
9169
+ var SEVERITY_PREFIX = {
9170
+ info: ":information_source:",
9171
+ success: ":white_check_mark:",
9172
+ warning: ":warning:",
9173
+ error: ":x:"
9174
+ };
9175
+ var SlackSink = class {
9176
+ kind = "slack";
9177
+ id;
9178
+ webhookUrl;
9179
+ fetchImpl;
9180
+ timeoutMs;
9181
+ constructor(opts) {
9182
+ this.id = opts.id;
9183
+ this.webhookUrl = opts.webhookUrl;
9184
+ this.fetchImpl = opts.fetchImpl ?? fetch;
9185
+ this.timeoutMs = opts.timeoutMs ?? 5e3;
9186
+ }
9187
+ async deliver(input) {
9188
+ const body = input.wrapped ? this.renderEnvelope(input.payload) : this.renderRawEvent(input.payload);
9189
+ const ctrl = new AbortController();
9190
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
9191
+ try {
9192
+ const res = await this.fetchImpl(this.webhookUrl, {
9193
+ method: "POST",
9194
+ headers: { "Content-Type": "application/json" },
9195
+ body: JSON.stringify(body),
9196
+ signal: ctrl.signal
9197
+ });
9198
+ if (res.ok) {
9199
+ return { ok: true, deliveredAt: Date.now() };
9200
+ }
9201
+ return { ok: false, error: `HTTP ${res.status}`, httpStatus: res.status };
9202
+ } catch (err) {
9203
+ const msg = err instanceof Error ? err.message : String(err);
9204
+ return { ok: false, error: ctrl.signal.aborted ? "timeout" : msg };
9205
+ } finally {
9206
+ clearTimeout(timer);
9207
+ }
9208
+ }
9209
+ renderEnvelope(env) {
9210
+ const prefix = SEVERITY_PREFIX[env.severity] ?? "";
9211
+ const headline = `${prefix} ${env.title}`.trim();
9212
+ const blocks = [
9213
+ { type: "section", text: { type: "mrkdwn", text: `*${headline}*
9214
+ ${env.summary}` } }
9215
+ ];
9216
+ if (env.actions && env.actions.length > 0) {
9217
+ blocks.push({
9218
+ type: "actions",
9219
+ elements: env.actions.map((a) => ({
9220
+ type: "button",
9221
+ text: { type: "plain_text", text: a.label },
9222
+ url: a.url
9223
+ }))
9224
+ });
9225
+ }
9226
+ if (env.permalink) {
9227
+ blocks.push({
9228
+ type: "section",
9229
+ text: { type: "mrkdwn", text: `<${env.permalink}|View details>` }
9230
+ });
9231
+ }
9232
+ return { text: headline, blocks };
9233
+ }
9234
+ renderRawEvent(event) {
9235
+ const dump = (() => {
9236
+ try {
9237
+ return JSON.stringify(event.data, null, 2);
9238
+ } catch {
9239
+ return String(event.data);
9240
+ }
9241
+ })();
9242
+ const text = `harness event: \`${event.type}\``;
9243
+ return {
9244
+ text,
9245
+ blocks: [
9246
+ { type: "section", text: { type: "mrkdwn", text: `*${text}*
9247
+ \`\`\`
9248
+ ${dump}
9249
+ \`\`\`` } }
9250
+ ]
9251
+ };
9252
+ }
9253
+ };
9254
+
9255
+ // src/notifications/registry.ts
9256
+ var SinkConfigError = class extends Error {
9257
+ constructor(sinkId, message) {
9258
+ super(`[sink:${sinkId}] ${message}`);
9259
+ this.sinkId = sinkId;
9260
+ this.name = "SinkConfigError";
9261
+ }
9262
+ sinkId;
9263
+ };
9264
+ var SinkRegistry = class _SinkRegistry {
9265
+ entries;
9266
+ constructor(entries) {
9267
+ this.entries = entries;
9268
+ }
9269
+ static fromConfig(config, options) {
9270
+ const entries = [];
9271
+ for (const sinkConfig of config.sinks) {
9272
+ entries.push({
9273
+ config: sinkConfig,
9274
+ adapter: buildSink(sinkConfig, options)
9275
+ });
9276
+ }
9277
+ return new _SinkRegistry(entries);
9278
+ }
9279
+ list() {
9280
+ return this.entries;
9281
+ }
9282
+ get(id) {
9283
+ return this.entries.find((e) => e.config.id === id) ?? null;
9284
+ }
9285
+ ids() {
9286
+ return this.entries.map((e) => e.config.id);
9287
+ }
9288
+ async dispose() {
9289
+ for (const entry of this.entries) {
9290
+ if (entry.adapter.dispose) {
9291
+ await entry.adapter.dispose();
9292
+ }
9293
+ }
9294
+ }
9295
+ };
9296
+ function buildSink(config, options) {
9297
+ const kind = config.kind;
9298
+ switch (kind) {
9299
+ case "slack":
9300
+ return buildSlackSink(config, options);
9301
+ default: {
9302
+ const _exhaustive = kind;
9303
+ throw new SinkConfigError(config.id, `unknown sink kind '${String(_exhaustive)}'`);
9304
+ }
9305
+ }
9306
+ }
9307
+ function buildSlackSink(config, options) {
9308
+ const rawConfig = config.config;
9309
+ const envKey = typeof rawConfig.webhookUrlEnv === "string" ? rawConfig.webhookUrlEnv : null;
9310
+ const inlineUrl = typeof rawConfig.webhookUrl === "string" ? rawConfig.webhookUrl : null;
9311
+ let url;
9312
+ if (envKey) {
9313
+ const v = options.env[envKey];
9314
+ if (!v) {
9315
+ throw new SinkConfigError(
9316
+ config.id,
9317
+ `Slack webhook env var '${envKey}' is not set in the environment`
9318
+ );
9319
+ }
9320
+ url = v;
9321
+ } else if (inlineUrl) {
9322
+ url = inlineUrl;
9323
+ } else {
9324
+ throw new SinkConfigError(
9325
+ config.id,
9326
+ `Slack sink requires 'config.webhookUrlEnv' (preferred) or 'config.webhookUrl'`
9327
+ );
9328
+ }
9329
+ if (!/^https:\/\/hooks\.slack\.com\//.test(url)) {
9330
+ throw new SinkConfigError(
9331
+ config.id,
9332
+ `Slack webhook URL must be an https://hooks.slack.com/ URL`
9333
+ );
9334
+ }
9335
+ const sinkOpts = {
9336
+ id: config.id,
9337
+ webhookUrl: url
9338
+ };
9339
+ if (options.fetchImpl) sinkOpts.fetchImpl = options.fetchImpl;
9340
+ return new SlackSink(sinkOpts);
9341
+ }
9342
+
9343
+ // src/notifications/events.ts
9344
+ import { randomBytes as randomBytes8 } from "crypto";
9345
+
9346
+ // src/notifications/envelope.ts
9347
+ function asObj(data) {
9348
+ return typeof data === "object" && data !== null ? data : {};
9349
+ }
9350
+ var ENVELOPE_DERIVERS = {
9351
+ "maintenance.started": (event) => {
9352
+ const data = asObj(event.data);
9353
+ return {
9354
+ title: `Maintenance started: ${data.taskId ?? "(unknown task)"}`,
9355
+ summary: `Task \`${data.taskId ?? "(unknown)"}\` is running.`,
9356
+ severity: "info"
9357
+ };
9358
+ },
9359
+ "maintenance.completed": (event) => {
9360
+ const data = asObj(event.data);
9361
+ return {
9362
+ title: `Maintenance done: ${data.taskId ?? "(unknown task)"}`,
9363
+ summary: `Task \`${data.taskId ?? "(unknown)"}\` completed successfully.`,
9364
+ severity: "success"
9365
+ };
9366
+ },
9367
+ "maintenance.error": (event) => {
9368
+ const data = asObj(event.data);
9369
+ return {
9370
+ title: `Maintenance failed: ${data.taskId ?? "(unknown task)"}`,
9371
+ summary: data.error ?? "No error message provided.",
9372
+ severity: "error"
9373
+ };
9374
+ },
9375
+ "interaction.created": (event) => {
9376
+ const data = asObj(event.data);
9377
+ return {
9378
+ title: `Action required: ${truncate(data.question ?? "pending interaction", 80)}`,
9379
+ summary: data.question ?? "(no question text)",
9380
+ severity: "warning"
9381
+ };
9382
+ },
9383
+ "interaction.resolved": (event) => {
9384
+ const data = asObj(event.data);
9385
+ return {
9386
+ title: `Interaction resolved`,
9387
+ summary: data.resolution ?? "(no resolution text)",
9388
+ severity: "info"
9389
+ };
9390
+ },
9391
+ "notification.test": (event) => {
9392
+ const data = asObj(event.data);
9393
+ return {
9394
+ title: "Test notification from harness",
9395
+ summary: data.message ?? "If you see this, your notification sink is working.",
9396
+ severity: "info"
9397
+ };
9398
+ }
9399
+ };
9400
+ function truncate(s, max) {
9401
+ return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
9402
+ }
9403
+ function fallbackTitle(event) {
9404
+ return event.type;
9405
+ }
9406
+ function fallbackSummary(event) {
9407
+ try {
9408
+ return "```\n" + JSON.stringify(event.data, null, 2) + "\n```";
9409
+ } catch {
9410
+ return String(event.data);
9411
+ }
9412
+ }
9413
+ function severityFromType(type) {
9414
+ if (type.endsWith(".error") || type.endsWith(".failed")) return "error";
9415
+ if (type.endsWith(".completed") || type.endsWith(".resolved")) return "success";
9416
+ if (type.endsWith(".created") || type.startsWith("interaction.")) return "warning";
9417
+ return "info";
9418
+ }
9419
+ function backfillEnvelope(event, partial) {
9420
+ return {
9421
+ title: truncate(partial.title ?? fallbackTitle(event), 280),
9422
+ summary: partial.summary ?? fallbackSummary(event),
9423
+ severity: partial.severity ?? severityFromType(event.type)
9424
+ };
9425
+ }
9426
+ function wrapAsEnvelope(event) {
9427
+ const deriver = ENVELOPE_DERIVERS[event.type];
9428
+ const partial = deriver ? deriver(event) : {};
9429
+ const envelope = backfillEnvelope(event, partial);
9430
+ if (partial.actions) envelope.actions = partial.actions;
9431
+ if (partial.permalink) envelope.permalink = partial.permalink;
9432
+ if (event.correlationId) envelope.correlationId = event.correlationId;
9433
+ return envelope;
9434
+ }
9435
+
9436
+ // src/notifications/events.ts
9437
+ var NOTIFICATION_TOPICS = [
9438
+ "interaction.created",
9439
+ "interaction.resolved",
9440
+ "maintenance:started",
9441
+ "maintenance:completed",
9442
+ "maintenance:error"
9443
+ ];
9444
+ function newEventId4() {
9445
+ return `evt_${randomBytes8(8).toString("hex")}`;
9446
+ }
9447
+ function dispatchToEntry(bus, entry, event) {
9448
+ const eventType = event.type;
9449
+ const matches = entry.config.events.some((p) => eventMatches(p, eventType));
9450
+ if (!matches) return;
9451
+ const payload = entry.config.wrap_response ? wrapAsEnvelope(event) : event;
9452
+ const summaryBase = {
9453
+ sinkId: entry.adapter.id,
9454
+ kind: entry.adapter.kind,
9455
+ eventType,
9456
+ eventId: event.id
9457
+ };
9458
+ void entry.adapter.deliver({ payload, wrapped: entry.config.wrap_response }).then((result) => {
9459
+ bus.emit("notification.delivery.attempted", { ...summaryBase, ok: result.ok });
9460
+ if (!result.ok) {
9461
+ bus.emit("notification.delivery.failed", {
9462
+ ...summaryBase,
9463
+ ok: false,
9464
+ error: result.error
9465
+ });
9466
+ }
9467
+ }).catch((err) => {
9468
+ const msg = err instanceof Error ? err.message : String(err);
9469
+ bus.emit("notification.delivery.failed", { ...summaryBase, ok: false, error: msg });
9470
+ });
9471
+ }
9472
+ function wireNotificationSinks({ bus, registry }) {
9473
+ const handlers = [];
9474
+ for (const topic of NOTIFICATION_TOPICS) {
9475
+ const eventType = topic.replace(":", ".");
9476
+ const fn = (data) => {
9477
+ const entries = registry.list();
9478
+ if (entries.length === 0) return;
9479
+ const event = {
9480
+ id: newEventId4(),
9481
+ type: eventType,
9482
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9483
+ data
9484
+ };
9485
+ for (const entry of entries) {
9486
+ dispatchToEntry(bus, entry, event);
9487
+ }
9488
+ };
9489
+ bus.on(topic, fn);
9490
+ handlers.push({ topic, fn });
9491
+ }
9492
+ return () => {
9493
+ for (const { topic, fn } of handlers) bus.removeListener(topic, fn);
9494
+ };
9495
+ }
9496
+
8589
9497
  // src/orchestrator.ts
8590
9498
  import { CacheMetricsRecorder, OTLPExporter } from "@harness-engineering/core";
8591
9499
 
@@ -9142,10 +10050,10 @@ var MaintenanceScheduler = class {
9142
10050
  };
9143
10051
 
9144
10052
  // src/maintenance/leader-elector.ts
9145
- import { Ok as Ok20 } from "@harness-engineering/types";
10053
+ import { Ok as Ok22 } from "@harness-engineering/types";
9146
10054
  var SingleProcessLeaderElector = class {
9147
10055
  async electLeader() {
9148
- return Ok20("claimed");
10056
+ return Ok22("claimed");
9149
10057
  }
9150
10058
  };
9151
10059
 
@@ -9625,6 +10533,13 @@ var Orchestrator = class extends EventEmitter {
9625
10533
  cacheMetrics;
9626
10534
  otlpExporter;
9627
10535
  telemetryFanoutOff;
10536
+ // Hermes Phase 3: in-process notification sinks subscribe to the same
10537
+ // event bus (`this`) that webhook fanout uses, applying envelope
10538
+ // formatting before delivering to Slack/etc. The registry + unwire
10539
+ // handle are kept on the instance so stop() can detach listeners and
10540
+ // call adapter dispose() in deterministic order.
10541
+ notificationsRegistry;
10542
+ notificationFanoutOff;
9628
10543
  orchestratorIdPromise;
9629
10544
  recorder;
9630
10545
  intelligenceRunner;
@@ -9780,6 +10695,7 @@ var Orchestrator = class extends EventEmitter {
9780
10695
  delivery: webhookDelivery
9781
10696
  });
9782
10697
  webhookDelivery.start();
10698
+ this.setupNotifications(config.notifications);
9783
10699
  const otlpCfg = config.telemetry?.export?.otlp;
9784
10700
  if (otlpCfg) {
9785
10701
  this.otlpExporter = new OTLPExporter({
@@ -10615,6 +11531,31 @@ var Orchestrator = class extends EventEmitter {
10615
11531
  );
10616
11532
  this.emit("state_change", this.getSnapshot());
10617
11533
  }
11534
+ /**
11535
+ * Hermes Phase 3: wire in-process notification sinks against the
11536
+ * orchestrator's event bus (`this`). A misconfigured sink (unknown kind,
11537
+ * missing env var) logs + skips rather than breaking startup — the
11538
+ * hardened doctor (`harness doctor`) surfaces the gap. Sinks subscribe
11539
+ * to the same topics as `wireWebhookFanout`; a slow Slack call cannot
11540
+ * block webhook delivery because the two paths fan out independently.
11541
+ */
11542
+ setupNotifications(notifConfig) {
11543
+ if (!notifConfig || !notifConfig.sinks || notifConfig.sinks.length === 0) return;
11544
+ try {
11545
+ this.notificationsRegistry = SinkRegistry.fromConfig(notifConfig, {
11546
+ env: process.env
11547
+ });
11548
+ this.notificationFanoutOff = wireNotificationSinks({
11549
+ bus: this,
11550
+ registry: this.notificationsRegistry
11551
+ });
11552
+ } catch (err) {
11553
+ this.logger.warn(
11554
+ `notifications sink registry failed: ${err instanceof Error ? err.message : String(err)}; sinks disabled`
11555
+ );
11556
+ delete this.notificationsRegistry;
11557
+ }
11558
+ }
10618
11559
  /**
10619
11560
  * Stops execution for a specific issue.
10620
11561
  *
@@ -10782,6 +11723,14 @@ var Orchestrator = class extends EventEmitter {
10782
11723
  this.webhookFanoutOff();
10783
11724
  delete this.webhookFanoutOff;
10784
11725
  }
11726
+ if (this.notificationFanoutOff) {
11727
+ this.notificationFanoutOff();
11728
+ delete this.notificationFanoutOff;
11729
+ }
11730
+ if (this.notificationsRegistry) {
11731
+ await this.notificationsRegistry.dispose();
11732
+ delete this.notificationsRegistry;
11733
+ }
10785
11734
  if (this.telemetryFanoutOff) {
10786
11735
  this.telemetryFanoutOff();
10787
11736
  delete this.telemetryFanoutOff;
@@ -11072,7 +12021,7 @@ function launchTUI(orchestrator) {
11072
12021
  // src/maintenance/sync-main.ts
11073
12022
  import { execFile as nodeExecFile } from "child_process";
11074
12023
  import { promisify as promisify3 } from "util";
11075
- var DEFAULT_TIMEOUT_MS2 = 6e4;
12024
+ var DEFAULT_TIMEOUT_MS3 = 6e4;
11076
12025
  async function git(execFileFn, args, cwd, timeoutMs) {
11077
12026
  const exec = promisify3(execFileFn);
11078
12027
  const { stdout, stderr } = await exec("git", args, { cwd, timeout: timeoutMs });
@@ -11137,7 +12086,7 @@ async function isAncestor(execFileFn, a, b, cwd, timeoutMs) {
11137
12086
  }
11138
12087
  async function syncMain(repoRoot, opts = {}) {
11139
12088
  const execFileFn = opts.execFileFn ?? nodeExecFile;
11140
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
12089
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
11141
12090
  try {
11142
12091
  const originRef = await resolveOriginDefault(execFileFn, repoRoot, timeoutMs);
11143
12092
  if (!originRef) {
@@ -11212,6 +12161,465 @@ async function syncMain(repoRoot, opts = {}) {
11212
12161
  };
11213
12162
  }
11214
12163
  }
12164
+
12165
+ // src/sessions/search-index.ts
12166
+ import * as fs15 from "fs";
12167
+ import * as path17 from "path";
12168
+ import Database2 from "better-sqlite3";
12169
+ import { INDEXED_FILE_KINDS } from "@harness-engineering/types";
12170
+ var SEARCH_INDEX_FILE = "search-index.sqlite";
12171
+ var SCHEMA_SQL2 = `
12172
+ CREATE TABLE IF NOT EXISTS session_docs (
12173
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12174
+ session_id TEXT NOT NULL,
12175
+ archived INTEGER NOT NULL,
12176
+ file_kind TEXT NOT NULL,
12177
+ path TEXT NOT NULL,
12178
+ mtime_ms INTEGER NOT NULL,
12179
+ body TEXT NOT NULL,
12180
+ UNIQUE (session_id, archived, file_kind)
12181
+ );
12182
+
12183
+ CREATE VIRTUAL TABLE IF NOT EXISTS session_docs_fts USING fts5 (
12184
+ body,
12185
+ content='session_docs',
12186
+ content_rowid='id',
12187
+ tokenize='unicode61 remove_diacritics 2'
12188
+ );
12189
+
12190
+ CREATE TRIGGER IF NOT EXISTS session_docs_ai
12191
+ AFTER INSERT ON session_docs
12192
+ BEGIN INSERT INTO session_docs_fts(rowid, body) VALUES (new.id, new.body); END;
12193
+
12194
+ CREATE TRIGGER IF NOT EXISTS session_docs_ad
12195
+ AFTER DELETE ON session_docs
12196
+ BEGIN INSERT INTO session_docs_fts(session_docs_fts, rowid, body) VALUES('delete', old.id, old.body); END;
12197
+
12198
+ CREATE TRIGGER IF NOT EXISTS session_docs_au
12199
+ AFTER UPDATE ON session_docs
12200
+ BEGIN
12201
+ INSERT INTO session_docs_fts(session_docs_fts, rowid, body) VALUES('delete', old.id, old.body);
12202
+ INSERT INTO session_docs_fts(rowid, body) VALUES (new.id, new.body);
12203
+ END;
12204
+ `;
12205
+ var DEFAULT_LIMIT = 20;
12206
+ function normalizeFts5Query(query) {
12207
+ const advancedSyntax = /["()*^+]|\bAND\b|\bOR\b|\bNOT\b|[A-Za-z_]+:/;
12208
+ if (advancedSyntax.test(query)) return query;
12209
+ return query.split(/\s+/).filter((tok) => tok.length > 0).map((tok) => `"${tok.replace(/"/g, '""')}"`).join(" ");
12210
+ }
12211
+ function searchIndexPath(projectPath) {
12212
+ return path17.join(projectPath, ".harness", SEARCH_INDEX_FILE);
12213
+ }
12214
+ var FILE_KIND_TO_FILENAME = {
12215
+ summary: "summary.md",
12216
+ learnings: "learnings.md",
12217
+ failures: "failures.md",
12218
+ sections: "session-sections.md",
12219
+ llm_summary: "llm-summary.md"
12220
+ };
12221
+ var SqliteSearchIndex = class {
12222
+ db;
12223
+ upsertStmt;
12224
+ removeSessionStmt;
12225
+ totalStmt;
12226
+ constructor(dbPath) {
12227
+ fs15.mkdirSync(path17.dirname(dbPath), { recursive: true });
12228
+ this.db = new Database2(dbPath);
12229
+ this.db.pragma("journal_mode = WAL");
12230
+ this.db.pragma("synchronous = NORMAL");
12231
+ this.db.exec(SCHEMA_SQL2);
12232
+ this.upsertStmt = this.db.prepare(
12233
+ `INSERT INTO session_docs (session_id, archived, file_kind, path, mtime_ms, body)
12234
+ VALUES (@sessionId, @archived, @fileKind, @path, @mtimeMs, @body)
12235
+ ON CONFLICT(session_id, archived, file_kind) DO UPDATE SET
12236
+ path = excluded.path,
12237
+ mtime_ms = excluded.mtime_ms,
12238
+ body = excluded.body`
12239
+ );
12240
+ this.removeSessionStmt = this.db.prepare(`DELETE FROM session_docs WHERE session_id = ?`);
12241
+ this.totalStmt = this.db.prepare(`SELECT COUNT(*) AS n FROM session_docs`);
12242
+ }
12243
+ upsertSessionDoc(doc) {
12244
+ this.upsertStmt.run({
12245
+ sessionId: doc.sessionId,
12246
+ archived: doc.archived ? 1 : 0,
12247
+ fileKind: doc.fileKind,
12248
+ path: doc.path,
12249
+ mtimeMs: Math.floor(doc.mtimeMs),
12250
+ body: doc.body
12251
+ });
12252
+ }
12253
+ removeSession(sessionId) {
12254
+ const info = this.removeSessionStmt.run(sessionId);
12255
+ return info.changes;
12256
+ }
12257
+ /**
12258
+ * Drop all `archived=1` rows. Used by `reindexFromArchive` before a full
12259
+ * re-walk. Live (archived=0) rows are preserved.
12260
+ */
12261
+ resetArchived() {
12262
+ this.db.prepare(`DELETE FROM session_docs WHERE archived = 1`).run();
12263
+ }
12264
+ /** Total rows currently indexed (across both live and archived). */
12265
+ totalIndexed() {
12266
+ const row = this.totalStmt.get();
12267
+ return row.n;
12268
+ }
12269
+ /**
12270
+ * Ranked FTS5 query. Returns BM25-sorted matches. The `query` is passed to
12271
+ * FTS5 as-is; FTS5 syntax (phrases with quotes, AND/OR/NOT, `column:term`)
12272
+ * is therefore the user-facing language. Errors from malformed queries
12273
+ * surface as thrown `SqliteError` so the CLI can catch + render them.
12274
+ */
12275
+ search(query, opts = {}) {
12276
+ const limit = opts.limit ?? DEFAULT_LIMIT;
12277
+ const filters = [];
12278
+ const params = { q: normalizeFts5Query(query), limit };
12279
+ if (opts.archivedOnly) {
12280
+ filters.push("d.archived = 1");
12281
+ }
12282
+ const fileKinds = opts.fileKinds && opts.fileKinds.length > 0 ? opts.fileKinds : null;
12283
+ if (fileKinds) {
12284
+ const placeholders = fileKinds.map((_, i) => `@fk${i}`).join(", ");
12285
+ filters.push(`d.file_kind IN (${placeholders})`);
12286
+ fileKinds.forEach((k, i) => {
12287
+ params[`fk${i}`] = k;
12288
+ });
12289
+ }
12290
+ const whereClause = filters.length > 0 ? `AND ${filters.join(" AND ")}` : "";
12291
+ const sql = `
12292
+ SELECT
12293
+ d.session_id AS sessionId,
12294
+ d.archived AS archived,
12295
+ d.file_kind AS fileKind,
12296
+ d.path AS path,
12297
+ bm25(session_docs_fts) AS bm25,
12298
+ snippet(session_docs_fts, 0, '\u2026', '\u2026', '\u2026', 16) AS snippet
12299
+ FROM session_docs_fts
12300
+ JOIN session_docs d ON d.id = session_docs_fts.rowid
12301
+ WHERE session_docs_fts MATCH @q
12302
+ ${whereClause}
12303
+ ORDER BY bm25 ASC
12304
+ LIMIT @limit
12305
+ `;
12306
+ const start = Date.now();
12307
+ const rows = this.db.prepare(sql).all(params);
12308
+ const durationMs = Date.now() - start;
12309
+ const matches = rows.map((r) => ({
12310
+ sessionId: r.sessionId,
12311
+ archived: r.archived === 1,
12312
+ fileKind: r.fileKind,
12313
+ path: r.path,
12314
+ bm25: r.bm25,
12315
+ snippet: r.snippet
12316
+ }));
12317
+ return { matches, durationMs, totalIndexed: this.totalIndexed() };
12318
+ }
12319
+ close() {
12320
+ this.db.close();
12321
+ }
12322
+ };
12323
+ function openSearchIndex(projectPath) {
12324
+ return new SqliteSearchIndex(searchIndexPath(projectPath));
12325
+ }
12326
+ function indexSessionDirectory(idx, args) {
12327
+ const kinds = args.fileKinds ?? [...INDEXED_FILE_KINDS];
12328
+ const cap = args.maxBytesPerBody ?? 256 * 1024;
12329
+ let docsWritten = 0;
12330
+ for (const kind of kinds) {
12331
+ const fileName = FILE_KIND_TO_FILENAME[kind];
12332
+ const filePath = path17.join(args.sessionDir, fileName);
12333
+ if (!fs15.existsSync(filePath)) continue;
12334
+ let body = fs15.readFileSync(filePath, "utf8");
12335
+ if (Buffer.byteLength(body, "utf8") > cap) {
12336
+ body = body.slice(0, cap) + "\n\n[TRUNCATED]";
12337
+ }
12338
+ const stat = fs15.statSync(filePath);
12339
+ const relPath = path17.relative(args.projectPath, filePath).replaceAll("\\", "/");
12340
+ idx.upsertSessionDoc({
12341
+ sessionId: args.sessionId,
12342
+ archived: args.archived,
12343
+ fileKind: kind,
12344
+ path: relPath,
12345
+ mtimeMs: stat.mtimeMs,
12346
+ body
12347
+ });
12348
+ docsWritten++;
12349
+ }
12350
+ return { docsWritten };
12351
+ }
12352
+ function reindexFromArchive(projectPath, opts = {}) {
12353
+ const start = Date.now();
12354
+ const archiveBase = path17.join(projectPath, ".harness", "archive", "sessions");
12355
+ const idx = openSearchIndex(projectPath);
12356
+ try {
12357
+ idx.resetArchived();
12358
+ let sessionsIndexed = 0;
12359
+ let docsWritten = 0;
12360
+ if (fs15.existsSync(archiveBase)) {
12361
+ const entries = fs15.readdirSync(archiveBase, { withFileTypes: true });
12362
+ for (const entry of entries) {
12363
+ if (!entry.isDirectory()) continue;
12364
+ const sessionDir = path17.join(archiveBase, entry.name);
12365
+ const result = indexSessionDirectory(idx, {
12366
+ sessionId: entry.name,
12367
+ sessionDir,
12368
+ archived: true,
12369
+ projectPath,
12370
+ ...opts.fileKinds && { fileKinds: opts.fileKinds },
12371
+ ...opts.maxBytesPerBody !== void 0 && { maxBytesPerBody: opts.maxBytesPerBody }
12372
+ });
12373
+ if (result.docsWritten > 0) sessionsIndexed++;
12374
+ docsWritten += result.docsWritten;
12375
+ }
12376
+ }
12377
+ return { sessionsIndexed, docsWritten, durationMs: Date.now() - start };
12378
+ } finally {
12379
+ idx.close();
12380
+ }
12381
+ }
12382
+
12383
+ // src/sessions/summarize.ts
12384
+ import * as fs16 from "fs";
12385
+ import * as path18 from "path";
12386
+ import {
12387
+ SessionSummarySchema
12388
+ } from "@harness-engineering/types";
12389
+ import { Ok as Ok23, Err as Err20 } from "@harness-engineering/types";
12390
+ var LLM_SUMMARY_FILE = "llm-summary.md";
12391
+ var SUMMARY_INPUT_FILES = [
12392
+ { filename: "summary.md", kind: "summary" },
12393
+ { filename: "learnings.md", kind: "learnings" },
12394
+ { filename: "failures.md", kind: "failures" },
12395
+ { filename: "session-sections.md", kind: "sections" }
12396
+ ];
12397
+ var DEFAULT_INPUT_BUDGET_TOKENS = 16e3;
12398
+ var DEFAULT_TIMEOUT_MS4 = 6e4;
12399
+ var CHARS_PER_TOKEN = 4;
12400
+ var SYSTEM_PROMPT = `You produce concise, structured retrospectives of completed harness-engineering sessions.
12401
+
12402
+ Read the session's archived markdown files and emit a JSON object that conforms exactly to the provided schema. Be specific and grounded \u2014 quote artefacts (file names, skill names, error messages) verbatim when relevant. Do not invent. If a field has no content, return an empty array.`;
12403
+ var USER_PROMPT_PREAMBLE = `Below are the archived files for a single harness-engineering session. Produce a structured summary capturing:
12404
+ - headline: one-sentence retrospective (\u2264 120 chars)
12405
+ - keyOutcomes: concrete things that shipped / decisions made (\u2264 20 strings)
12406
+ - openQuestions: items still open (\u2264 20 strings)
12407
+ - relatedSessions: other session slugs referenced (may be empty)
12408
+
12409
+ ---
12410
+
12411
+ `;
12412
+ function readInputCorpus(archiveDir) {
12413
+ const parts = [];
12414
+ for (const { filename, kind } of SUMMARY_INPUT_FILES) {
12415
+ const p = path18.join(archiveDir, filename);
12416
+ if (!fs16.existsSync(p)) continue;
12417
+ try {
12418
+ const content = fs16.readFileSync(p, "utf8");
12419
+ if (content.trim().length === 0) continue;
12420
+ parts.push(`## FILE: ${kind}
12421
+
12422
+ ${content.trim()}`);
12423
+ } catch {
12424
+ }
12425
+ }
12426
+ return parts.join("\n\n");
12427
+ }
12428
+ function truncateForBudget(text, inputBudgetTokens) {
12429
+ const cap = Math.max(0, inputBudgetTokens * CHARS_PER_TOKEN);
12430
+ if (text.length <= cap) return text;
12431
+ return text.slice(0, cap) + "\n\n[TRUNCATED \u2014 input exceeded token budget]";
12432
+ }
12433
+ function renderLlmSummaryMarkdown(summary, meta) {
12434
+ const lines = [
12435
+ "---",
12436
+ `generatedAt: ${meta.generatedAt}`,
12437
+ `model: ${meta.model}`,
12438
+ `inputTokens: ${meta.inputTokens}`,
12439
+ `outputTokens: ${meta.outputTokens}`,
12440
+ `schemaVersion: ${meta.schemaVersion}`,
12441
+ "---",
12442
+ "",
12443
+ "## Headline",
12444
+ summary.headline,
12445
+ "",
12446
+ "## Key outcomes"
12447
+ ];
12448
+ if (summary.keyOutcomes.length === 0) {
12449
+ lines.push("_(none)_");
12450
+ } else {
12451
+ for (const item of summary.keyOutcomes) lines.push(`- ${item}`);
12452
+ }
12453
+ lines.push("", "## Open questions");
12454
+ if (summary.openQuestions.length === 0) {
12455
+ lines.push("_(none)_");
12456
+ } else {
12457
+ for (const item of summary.openQuestions) lines.push(`- ${item}`);
12458
+ }
12459
+ lines.push("", "## Related sessions");
12460
+ if (summary.relatedSessions.length === 0) {
12461
+ lines.push("_(none)_");
12462
+ } else {
12463
+ for (const item of summary.relatedSessions) lines.push(`- ${item}`);
12464
+ }
12465
+ lines.push("");
12466
+ return lines.join("\n");
12467
+ }
12468
+ function writeStubMarkdown(archiveDir, reason) {
12469
+ const filePath = path18.join(archiveDir, LLM_SUMMARY_FILE);
12470
+ const body = `---
12471
+ generatedAt: ${(/* @__PURE__ */ new Date()).toISOString()}
12472
+ schemaVersion: 1
12473
+ status: failed
12474
+ ---
12475
+
12476
+ ## Summary unavailable
12477
+
12478
+ - reason: ${reason}
12479
+ `;
12480
+ fs16.writeFileSync(filePath, body, "utf8");
12481
+ return filePath;
12482
+ }
12483
+ async function summarizeArchivedSession(ctx) {
12484
+ const writeStubOnError = ctx.writeStubOnError ?? true;
12485
+ if (!fs16.existsSync(ctx.archiveDir)) {
12486
+ return Err20(new Error(`archive directory not found: ${ctx.archiveDir}`));
12487
+ }
12488
+ const corpus = readInputCorpus(ctx.archiveDir);
12489
+ if (corpus.trim().length === 0) {
12490
+ return Err20(new Error(`no summary input files found in ${ctx.archiveDir}`));
12491
+ }
12492
+ const inputBudgetTokens = ctx.config?.inputBudgetTokens ?? DEFAULT_INPUT_BUDGET_TOKENS;
12493
+ const truncated = truncateForBudget(corpus, inputBudgetTokens);
12494
+ const prompt = USER_PROMPT_PREAMBLE + truncated;
12495
+ const timeoutMs = ctx.config?.timeoutMs ?? DEFAULT_TIMEOUT_MS4;
12496
+ const analyzeOpts = {
12497
+ prompt,
12498
+ systemPrompt: SYSTEM_PROMPT,
12499
+ responseSchema: SessionSummarySchema,
12500
+ ...ctx.config?.model && { model: ctx.config.model }
12501
+ };
12502
+ let response;
12503
+ try {
12504
+ response = await Promise.race([
12505
+ ctx.provider.analyze(analyzeOpts),
12506
+ new Promise(
12507
+ (_, reject) => setTimeout(
12508
+ () => reject(new Error(`provider call timed out after ${timeoutMs}ms`)),
12509
+ timeoutMs
12510
+ )
12511
+ )
12512
+ ]);
12513
+ } catch (e) {
12514
+ const reason = e instanceof Error ? e.message : String(e);
12515
+ ctx.logger?.warn?.("session summary: provider call failed", { reason });
12516
+ let stubPath;
12517
+ if (writeStubOnError) {
12518
+ try {
12519
+ stubPath = writeStubMarkdown(ctx.archiveDir, reason);
12520
+ } catch {
12521
+ }
12522
+ }
12523
+ return Err20(
12524
+ new Error(`session summary failed: ${reason}` + (stubPath ? ` (stub: ${stubPath})` : ""))
12525
+ );
12526
+ }
12527
+ const parsed = SessionSummarySchema.safeParse(response.result);
12528
+ if (!parsed.success) {
12529
+ const reason = `schema validation failed: ${parsed.error.message}`;
12530
+ ctx.logger?.warn?.("session summary: invalid provider payload", { reason });
12531
+ if (writeStubOnError) {
12532
+ try {
12533
+ writeStubMarkdown(ctx.archiveDir, reason);
12534
+ } catch {
12535
+ }
12536
+ }
12537
+ return Err20(new Error(reason));
12538
+ }
12539
+ const meta = {
12540
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12541
+ model: response.model,
12542
+ inputTokens: response.tokenUsage.inputTokens,
12543
+ outputTokens: response.tokenUsage.outputTokens,
12544
+ schemaVersion: 1
12545
+ };
12546
+ const filePath = path18.join(ctx.archiveDir, LLM_SUMMARY_FILE);
12547
+ const body = renderLlmSummaryMarkdown(parsed.data, meta);
12548
+ fs16.writeFileSync(filePath, body, "utf8");
12549
+ return Ok23({ summary: parsed.data, meta, filePath });
12550
+ }
12551
+ function isSummaryEnabled(config) {
12552
+ if (!config) return false;
12553
+ if (config.enabled === false) return false;
12554
+ return true;
12555
+ }
12556
+
12557
+ // src/sessions/archive-hooks.ts
12558
+ var defaultLogger = {
12559
+ warn: (msg, meta) => console.warn(`[sessions] ${msg}`, meta)
12560
+ };
12561
+ async function runSummaryStep(opts, logger, sessionId, archiveDir) {
12562
+ const enabled = isSummaryEnabled(opts.config?.summary) && opts.provider != null;
12563
+ if (!enabled || !opts.provider) return;
12564
+ const ctx = {
12565
+ archiveDir,
12566
+ provider: opts.provider,
12567
+ ...opts.config?.summary && { config: opts.config.summary },
12568
+ ...logger && { logger }
12569
+ };
12570
+ try {
12571
+ const result = await summarizeArchivedSession(ctx);
12572
+ if (!result.ok) {
12573
+ logger.warn?.("session summary: failed", {
12574
+ sessionId,
12575
+ error: result.error.message
12576
+ });
12577
+ }
12578
+ } catch (e) {
12579
+ logger.warn?.("session summary: threw", {
12580
+ sessionId,
12581
+ error: e instanceof Error ? e.message : String(e)
12582
+ });
12583
+ }
12584
+ }
12585
+ function runIndexStep(opts, logger, sessionId, archiveDir) {
12586
+ try {
12587
+ const idx = openSearchIndex(opts.projectPath);
12588
+ try {
12589
+ const result = indexSessionDirectory(idx, {
12590
+ sessionId,
12591
+ sessionDir: archiveDir,
12592
+ archived: true,
12593
+ projectPath: opts.projectPath,
12594
+ ...opts.config?.search?.indexedFileKinds && {
12595
+ fileKinds: opts.config.search.indexedFileKinds
12596
+ },
12597
+ ...opts.config?.search?.maxIndexBytesPerFile !== void 0 && {
12598
+ maxBytesPerBody: opts.config.search.maxIndexBytesPerFile
12599
+ }
12600
+ });
12601
+ if (result.docsWritten === 0) {
12602
+ logger.warn?.("session index: no docs written", { sessionId, archiveDir });
12603
+ }
12604
+ } finally {
12605
+ idx.close();
12606
+ }
12607
+ } catch (e) {
12608
+ logger.warn?.("session index: failed", {
12609
+ sessionId,
12610
+ error: e instanceof Error ? e.message : String(e)
12611
+ });
12612
+ }
12613
+ }
12614
+ function buildArchiveHooks(opts) {
12615
+ const logger = opts.logger ?? defaultLogger;
12616
+ return {
12617
+ async onArchived({ sessionId, archiveDir }) {
12618
+ await runSummaryStep(opts, logger, sessionId, archiveDir);
12619
+ runIndexStep(opts, logger, sessionId, archiveDir);
12620
+ }
12621
+ };
12622
+ }
11215
12623
  export {
11216
12624
  AnalysisArchive,
11217
12625
  BackendRouter,
@@ -11227,6 +12635,10 @@ export {
11227
12635
  PromptRenderer,
11228
12636
  RETRY_DELAYS_MS,
11229
12637
  RoadmapTrackerAdapter,
12638
+ SinkConfigError,
12639
+ SinkRegistry,
12640
+ SlackSink,
12641
+ SqliteSearchIndex,
11230
12642
  StreamRecorder,
11231
12643
  TokenStore,
11232
12644
  WebhookQueue,
@@ -11235,6 +12647,7 @@ export {
11235
12647
  WorkspaceManager,
11236
12648
  applyEvent,
11237
12649
  artifactPresenceFromIssue,
12650
+ buildArchiveHooks,
11238
12651
  calculateRetryDelay,
11239
12652
  canDispatch,
11240
12653
  computeRateLimitDelay,
@@ -11246,20 +12659,31 @@ export {
11246
12659
  getAvailableSlots,
11247
12660
  getDefaultConfig,
11248
12661
  getPerStateCount,
12662
+ indexSessionDirectory,
11249
12663
  isEligible,
12664
+ isSummaryEnabled,
11250
12665
  launchTUI,
11251
12666
  loadPublishedIndex,
11252
12667
  migrateAgentConfig,
12668
+ normalizeFts5Query,
12669
+ openSearchIndex,
11253
12670
  reconcile,
12671
+ reindexFromArchive,
11254
12672
  renderAnalysisComment,
12673
+ renderLlmSummaryMarkdown,
11255
12674
  renderPRComment,
11256
12675
  resolveEscalationConfig,
11257
12676
  resolveOrchestratorId,
11258
12677
  routeIssue,
11259
12678
  savePublishedIndex,
12679
+ searchIndexPath,
11260
12680
  selectCandidates,
11261
12681
  sortCandidates,
12682
+ summarizeArchivedSession,
11262
12683
  syncMain,
11263
12684
  triageIssue,
11264
- validateWorkflowConfig
12685
+ truncateForBudget,
12686
+ validateWorkflowConfig,
12687
+ wireNotificationSinks,
12688
+ wrapAsEnvelope
11265
12689
  };