@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.d.mts +287 -4
- package/dist/index.d.ts +287 -4
- package/dist/index.js +1529 -97
- package/dist/index.mjs +1497 -73
- package/package.json +4 -4
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 = (
|
|
1873
|
+
const checkRef = (path19, name) => {
|
|
1874
1874
|
if (name !== void 0 && !names.has(name)) {
|
|
1875
1875
|
issues.push({
|
|
1876
|
-
path:
|
|
1877
|
-
message: `routing.${
|
|
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
|
|
3639
|
-
if (CASE1_ALWAYS_SUPPRESS.has(
|
|
3640
|
-
if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(
|
|
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 '${
|
|
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
|
-
(
|
|
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 = (
|
|
3783
|
-
if (name !== void 0 && !known.has(name)) missing.push({ path:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5118
|
-
import { Ok as
|
|
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
|
|
5728
|
+
return Ok18({ containerId, runtime: this.name });
|
|
5152
5729
|
} catch (error) {
|
|
5153
|
-
return
|
|
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 =
|
|
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
|
|
5774
|
+
return Ok18(void 0);
|
|
5198
5775
|
} catch (error) {
|
|
5199
|
-
return
|
|
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
|
|
5786
|
+
return Ok18(void 0);
|
|
5210
5787
|
} catch (error) {
|
|
5211
|
-
return
|
|
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
|
|
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
|
|
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
|
|
5814
|
+
return Ok19(secrets);
|
|
5238
5815
|
}
|
|
5239
5816
|
async healthCheck() {
|
|
5240
|
-
return
|
|
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
|
|
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
|
|
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
|
|
5855
|
+
return Ok20(secrets);
|
|
5279
5856
|
}
|
|
5280
5857
|
async healthCheck() {
|
|
5281
5858
|
try {
|
|
5282
5859
|
await opExec(["--version"]);
|
|
5283
|
-
return
|
|
5860
|
+
return Ok20(void 0);
|
|
5284
5861
|
} catch (error) {
|
|
5285
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5915
|
+
return Ok21(secrets);
|
|
5339
5916
|
}
|
|
5340
5917
|
async healthCheck() {
|
|
5341
5918
|
try {
|
|
5342
5919
|
await vaultExec(["version"]);
|
|
5343
|
-
return
|
|
5920
|
+
return Ok21(void 0);
|
|
5344
5921
|
} catch (error) {
|
|
5345
|
-
return
|
|
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
|
|
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 =
|
|
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(
|
|
7366
|
-
this.path =
|
|
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(
|
|
7474
|
-
this.path =
|
|
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,
|
|
8148
|
+
function requiredBridgeScope(method, path19) {
|
|
7570
8149
|
for (const r of V1_BRIDGE_ROUTES) {
|
|
7571
|
-
if (r.method === method && r.pattern.test(
|
|
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,
|
|
7582
|
-
const bridgeScope = requiredBridgeScope(method,
|
|
8160
|
+
function requiredScopeForRoute(method, path19) {
|
|
8161
|
+
const bridgeScope = requiredBridgeScope(method, path19);
|
|
7583
8162
|
if (bridgeScope) return bridgeScope;
|
|
7584
|
-
if (
|
|
7585
|
-
if (
|
|
7586
|
-
if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(
|
|
7587
|
-
if ((
|
|
7588
|
-
if (
|
|
7589
|
-
if (
|
|
7590
|
-
if (
|
|
7591
|
-
if (
|
|
7592
|
-
if (
|
|
7593
|
-
if (
|
|
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 (
|
|
7596
|
-
if (
|
|
7597
|
-
if (
|
|
7598
|
-
if (
|
|
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(
|
|
8003
|
-
this.path =
|
|
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
|
|
10053
|
+
import { Ok as Ok22 } from "@harness-engineering/types";
|
|
9146
10054
|
var SingleProcessLeaderElector = class {
|
|
9147
10055
|
async electLeader() {
|
|
9148
|
-
return
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
12685
|
+
truncateForBudget,
|
|
12686
|
+
validateWorkflowConfig,
|
|
12687
|
+
wireNotificationSinks,
|
|
12688
|
+
wrapAsEnvelope
|
|
11265
12689
|
};
|