@harness-engineering/orchestrator 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -0
- package/dist/index.js +70 -0
- package/dist/index.mjs +78 -0
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @harness-engineering/orchestrator
|
|
2
|
+
|
|
3
|
+
Orchestrator daemon for dispatching coding agents to issues. Polls an issue tracker for candidate tasks, manages ephemeral workspaces, runs agents to resolve issues, and updates the tracker with progress.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌──────────────────────────────────────────────────┐
|
|
9
|
+
│ Issue Tracker │
|
|
10
|
+
│ RoadmapTrackerAdapter · Linear Extension │
|
|
11
|
+
└──────────────────┬───────────────────────────────┘
|
|
12
|
+
▼
|
|
13
|
+
┌──────────────────────────────────────────────────┐
|
|
14
|
+
│ Core State Machine │
|
|
15
|
+
│ Candidate Selection · Concurrency Control │
|
|
16
|
+
│ Reconciliation · Retry Logic │
|
|
17
|
+
│ Event Sourcing (applyEvent → side effects) │
|
|
18
|
+
└──────────────────┬───────────────────────────────┘
|
|
19
|
+
▼
|
|
20
|
+
┌────────────────┐ ┌─────────────────┐
|
|
21
|
+
│ Agent Runner │ │ Workspaces │
|
|
22
|
+
│ Claude Backend│ │ WorkspaceManager│
|
|
23
|
+
│ Mock Backend │ │ WorkspaceHooks │
|
|
24
|
+
└───────┬────────┘ └────────┬────────┘
|
|
25
|
+
└────────┬───────────┘
|
|
26
|
+
▼
|
|
27
|
+
┌──────────────────────────────────────────────────┐
|
|
28
|
+
│ Prompt Rendering (LiquidJS) │
|
|
29
|
+
└──────────────────────────────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import {
|
|
36
|
+
Orchestrator,
|
|
37
|
+
loadWorkflowConfig,
|
|
38
|
+
WorkspaceManager,
|
|
39
|
+
WorkspaceHooks,
|
|
40
|
+
ClaudeBackend,
|
|
41
|
+
PromptRenderer,
|
|
42
|
+
RoadmapTrackerAdapter,
|
|
43
|
+
} from '@harness-engineering/orchestrator';
|
|
44
|
+
|
|
45
|
+
// Load workflow configuration
|
|
46
|
+
const config = loadWorkflowConfig('./workflow.yaml');
|
|
47
|
+
|
|
48
|
+
// Create and start the orchestrator
|
|
49
|
+
const orchestrator = new Orchestrator(config);
|
|
50
|
+
await orchestrator.start();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Core Concepts
|
|
54
|
+
|
|
55
|
+
### Event-Sourced State Machine
|
|
56
|
+
|
|
57
|
+
The orchestrator uses an event-sourced architecture. All state transitions are modeled as events (`OrchestratorEvent`) that produce side effects (`SideEffect`):
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { applyEvent, createEmptyState } from '@harness-engineering/orchestrator';
|
|
61
|
+
|
|
62
|
+
const state = createEmptyState();
|
|
63
|
+
const { state: next, effects } = applyEvent(state, event);
|
|
64
|
+
// effects: DispatchEffect, StopEffect, ScheduleRetryEffect, etc.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Candidate Selection
|
|
68
|
+
|
|
69
|
+
Issues are ranked and filtered before dispatch:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { sortCandidates, selectCandidates, isEligible } from '@harness-engineering/orchestrator';
|
|
73
|
+
|
|
74
|
+
const ranked = sortCandidates(issues);
|
|
75
|
+
const selected = selectCandidates(ranked, availableSlots);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Agent Backends
|
|
79
|
+
|
|
80
|
+
Two backends are available:
|
|
81
|
+
|
|
82
|
+
- **`ClaudeBackend`** — Production backend using the Claude API
|
|
83
|
+
- **`MockBackend`** — Test backend for deterministic behavior in tests
|
|
84
|
+
|
|
85
|
+
### Workspace Management
|
|
86
|
+
|
|
87
|
+
Each agent run gets an ephemeral workspace with lifecycle hooks:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { WorkspaceManager, WorkspaceHooks } from '@harness-engineering/orchestrator';
|
|
91
|
+
|
|
92
|
+
const manager = new WorkspaceManager(config);
|
|
93
|
+
const hooks = new WorkspaceHooks(config);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
### Orchestrator
|
|
99
|
+
|
|
100
|
+
The main class that ties everything together:
|
|
101
|
+
|
|
102
|
+
- `start()` — Begin polling and dispatching
|
|
103
|
+
- `stop()` — Gracefully shut down
|
|
104
|
+
- Events: `state_change`, `agent_event`
|
|
105
|
+
|
|
106
|
+
### Core Functions
|
|
107
|
+
|
|
108
|
+
| Export | Description |
|
|
109
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
110
|
+
| `applyEvent` | Apply an event to state, returning new state + side effects |
|
|
111
|
+
| `createEmptyState` | Create an initial empty orchestrator state |
|
|
112
|
+
| `sortCandidates` | Rank issues by priority and eligibility |
|
|
113
|
+
| `selectCandidates` | Select top candidates within concurrency limits |
|
|
114
|
+
| `isEligible` | Check if an issue is eligible for dispatch |
|
|
115
|
+
| `getAvailableSlots` | Get number of available agent slots |
|
|
116
|
+
| `canDispatch` | Check if dispatch is possible given current state |
|
|
117
|
+
| `reconcile` | Reconcile expected state against actual state |
|
|
118
|
+
| `calculateRetryDelay` | Compute exponential backoff delay |
|
|
119
|
+
|
|
120
|
+
### Tracker Adapters
|
|
121
|
+
|
|
122
|
+
| Export | Description |
|
|
123
|
+
| ------------------------ | ---------------------------------------- |
|
|
124
|
+
| `RoadmapTrackerAdapter` | Reads issues from `docs/roadmap.md` |
|
|
125
|
+
| `LinearTrackerExtension` | Extends tracking with Linear integration |
|
|
126
|
+
|
|
127
|
+
### Prompt Rendering
|
|
128
|
+
|
|
129
|
+
| Export | Description |
|
|
130
|
+
| ---------------- | -------------------------------------------- |
|
|
131
|
+
| `PromptRenderer` | Renders LiquidJS templates for agent prompts |
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -988,7 +988,41 @@ var StructuredLogger = class {
|
|
|
988
988
|
}
|
|
989
989
|
};
|
|
990
990
|
|
|
991
|
+
// src/workspace/config-scanner.ts
|
|
992
|
+
var import_node_fs = require("fs");
|
|
993
|
+
var import_node_path = require("path");
|
|
994
|
+
var import_core2 = require("@harness-engineering/core");
|
|
995
|
+
var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
|
|
996
|
+
function scanSingleFile(filePath, targetDir, scanner) {
|
|
997
|
+
if (!(0, import_node_fs.existsSync)(filePath)) return null;
|
|
998
|
+
let content;
|
|
999
|
+
try {
|
|
1000
|
+
content = (0, import_node_fs.readFileSync)(filePath, "utf8");
|
|
1001
|
+
} catch {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
const injectionFindings = (0, import_core2.scanForInjection)(content);
|
|
1005
|
+
const findings = (0, import_core2.mapInjectionFindings)(injectionFindings);
|
|
1006
|
+
const secFindings = scanner.scanContent(content, filePath);
|
|
1007
|
+
findings.push(...(0, import_core2.mapSecurityFindings)(secFindings, findings));
|
|
1008
|
+
return {
|
|
1009
|
+
file: (0, import_node_path.relative)(targetDir, filePath).replaceAll("\\", "/"),
|
|
1010
|
+
findings,
|
|
1011
|
+
overallSeverity: (0, import_core2.computeOverallSeverity)(findings)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
async function scanWorkspaceConfig(workspacePath) {
|
|
1015
|
+
const scanner = new import_core2.SecurityScanner((0, import_core2.parseSecurityConfig)({}));
|
|
1016
|
+
const results = [];
|
|
1017
|
+
for (const configFile of CONFIG_FILES) {
|
|
1018
|
+
const result = scanSingleFile((0, import_node_path.join)(workspacePath, configFile), workspacePath, scanner);
|
|
1019
|
+
if (result) results.push(result);
|
|
1020
|
+
}
|
|
1021
|
+
return { exitCode: (0, import_core2.computeScanExitCode)(results), results };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
991
1024
|
// src/orchestrator.ts
|
|
1025
|
+
var import_core4 = require("@harness-engineering/core");
|
|
992
1026
|
var Orchestrator = class extends import_node_events.EventEmitter {
|
|
993
1027
|
state;
|
|
994
1028
|
config;
|
|
@@ -1113,6 +1147,42 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
1113
1147
|
const workspaceResult = await this.workspace.ensureWorkspace(issue.identifier);
|
|
1114
1148
|
if (!workspaceResult.ok) throw workspaceResult.error;
|
|
1115
1149
|
const workspacePath = workspaceResult.value;
|
|
1150
|
+
const scanResult = await scanWorkspaceConfig(workspacePath);
|
|
1151
|
+
if (scanResult.exitCode === 2) {
|
|
1152
|
+
const findingSummary = scanResult.results.flatMap((r) => r.findings.filter((f) => f.severity === "high")).map((f) => `${f.ruleId}: ${f.message}`).join("; ");
|
|
1153
|
+
this.logger.error(
|
|
1154
|
+
`Config scan blocked dispatch for ${issue.identifier}: ${findingSummary}`,
|
|
1155
|
+
{ issueId: issue.id }
|
|
1156
|
+
);
|
|
1157
|
+
await this.emitWorkerExit(
|
|
1158
|
+
issue.id,
|
|
1159
|
+
"error",
|
|
1160
|
+
attempt,
|
|
1161
|
+
`Config scan found high-severity injection patterns: ${findingSummary}`
|
|
1162
|
+
);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (scanResult.exitCode === 1) {
|
|
1166
|
+
const findings = scanResult.results.flatMap(
|
|
1167
|
+
(r) => r.findings.filter((f) => f.severity === "medium").map((f) => ({
|
|
1168
|
+
ruleId: f.ruleId,
|
|
1169
|
+
severity: f.severity,
|
|
1170
|
+
match: f.match,
|
|
1171
|
+
...f.line !== void 0 ? { line: f.line } : {}
|
|
1172
|
+
}))
|
|
1173
|
+
);
|
|
1174
|
+
(0, import_core4.writeTaint)(
|
|
1175
|
+
workspacePath,
|
|
1176
|
+
issue.id,
|
|
1177
|
+
"Medium-severity injection patterns found in workspace config files",
|
|
1178
|
+
findings,
|
|
1179
|
+
"orchestrator:scan-config"
|
|
1180
|
+
);
|
|
1181
|
+
this.logger.warn(
|
|
1182
|
+
`Config scan found medium-severity patterns for ${issue.identifier}. Session tainted.`,
|
|
1183
|
+
{ issueId: issue.id }
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1116
1186
|
const hookResult = await this.hooks.beforeRun(workspacePath);
|
|
1117
1187
|
if (!hookResult.ok) throw hookResult.error;
|
|
1118
1188
|
const prompt = await this.renderer.render(this.promptTemplate, {
|
package/dist/index.mjs
CHANGED
|
@@ -939,7 +939,49 @@ var StructuredLogger = class {
|
|
|
939
939
|
}
|
|
940
940
|
};
|
|
941
941
|
|
|
942
|
+
// src/workspace/config-scanner.ts
|
|
943
|
+
import { existsSync, readFileSync } from "fs";
|
|
944
|
+
import { join as join2, relative } from "path";
|
|
945
|
+
import {
|
|
946
|
+
scanForInjection,
|
|
947
|
+
SecurityScanner,
|
|
948
|
+
parseSecurityConfig,
|
|
949
|
+
mapInjectionFindings,
|
|
950
|
+
mapSecurityFindings,
|
|
951
|
+
computeOverallSeverity,
|
|
952
|
+
computeScanExitCode
|
|
953
|
+
} from "@harness-engineering/core";
|
|
954
|
+
var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
|
|
955
|
+
function scanSingleFile(filePath, targetDir, scanner) {
|
|
956
|
+
if (!existsSync(filePath)) return null;
|
|
957
|
+
let content;
|
|
958
|
+
try {
|
|
959
|
+
content = readFileSync(filePath, "utf8");
|
|
960
|
+
} catch {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
const injectionFindings = scanForInjection(content);
|
|
964
|
+
const findings = mapInjectionFindings(injectionFindings);
|
|
965
|
+
const secFindings = scanner.scanContent(content, filePath);
|
|
966
|
+
findings.push(...mapSecurityFindings(secFindings, findings));
|
|
967
|
+
return {
|
|
968
|
+
file: relative(targetDir, filePath).replaceAll("\\", "/"),
|
|
969
|
+
findings,
|
|
970
|
+
overallSeverity: computeOverallSeverity(findings)
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
async function scanWorkspaceConfig(workspacePath) {
|
|
974
|
+
const scanner = new SecurityScanner(parseSecurityConfig({}));
|
|
975
|
+
const results = [];
|
|
976
|
+
for (const configFile of CONFIG_FILES) {
|
|
977
|
+
const result = scanSingleFile(join2(workspacePath, configFile), workspacePath, scanner);
|
|
978
|
+
if (result) results.push(result);
|
|
979
|
+
}
|
|
980
|
+
return { exitCode: computeScanExitCode(results), results };
|
|
981
|
+
}
|
|
982
|
+
|
|
942
983
|
// src/orchestrator.ts
|
|
984
|
+
import { writeTaint } from "@harness-engineering/core";
|
|
943
985
|
var Orchestrator = class extends EventEmitter {
|
|
944
986
|
state;
|
|
945
987
|
config;
|
|
@@ -1064,6 +1106,42 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1064
1106
|
const workspaceResult = await this.workspace.ensureWorkspace(issue.identifier);
|
|
1065
1107
|
if (!workspaceResult.ok) throw workspaceResult.error;
|
|
1066
1108
|
const workspacePath = workspaceResult.value;
|
|
1109
|
+
const scanResult = await scanWorkspaceConfig(workspacePath);
|
|
1110
|
+
if (scanResult.exitCode === 2) {
|
|
1111
|
+
const findingSummary = scanResult.results.flatMap((r) => r.findings.filter((f) => f.severity === "high")).map((f) => `${f.ruleId}: ${f.message}`).join("; ");
|
|
1112
|
+
this.logger.error(
|
|
1113
|
+
`Config scan blocked dispatch for ${issue.identifier}: ${findingSummary}`,
|
|
1114
|
+
{ issueId: issue.id }
|
|
1115
|
+
);
|
|
1116
|
+
await this.emitWorkerExit(
|
|
1117
|
+
issue.id,
|
|
1118
|
+
"error",
|
|
1119
|
+
attempt,
|
|
1120
|
+
`Config scan found high-severity injection patterns: ${findingSummary}`
|
|
1121
|
+
);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (scanResult.exitCode === 1) {
|
|
1125
|
+
const findings = scanResult.results.flatMap(
|
|
1126
|
+
(r) => r.findings.filter((f) => f.severity === "medium").map((f) => ({
|
|
1127
|
+
ruleId: f.ruleId,
|
|
1128
|
+
severity: f.severity,
|
|
1129
|
+
match: f.match,
|
|
1130
|
+
...f.line !== void 0 ? { line: f.line } : {}
|
|
1131
|
+
}))
|
|
1132
|
+
);
|
|
1133
|
+
writeTaint(
|
|
1134
|
+
workspacePath,
|
|
1135
|
+
issue.id,
|
|
1136
|
+
"Medium-severity injection patterns found in workspace config files",
|
|
1137
|
+
findings,
|
|
1138
|
+
"orchestrator:scan-config"
|
|
1139
|
+
);
|
|
1140
|
+
this.logger.warn(
|
|
1141
|
+
`Config scan found medium-severity patterns for ${issue.identifier}. Session tainted.`,
|
|
1142
|
+
{ issueId: issue.id }
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1067
1145
|
const hookResult = await this.hooks.beforeRun(workspacePath);
|
|
1068
1146
|
if (!hookResult.ok) throw hookResult.error;
|
|
1069
1147
|
const prompt = await this.renderer.render(this.promptTemplate, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-engineering/orchestrator",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Orchestrator daemon for dispatching coding agents to issues",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"chokidar": "^3.5.3",
|
|
42
42
|
"ink": "^4.4.1",
|
|
43
43
|
"react": "^18.2.0",
|
|
44
|
-
"@harness-engineering/core": "0.
|
|
45
|
-
"@harness-engineering/types": "0.
|
|
44
|
+
"@harness-engineering/core": "0.16.0",
|
|
45
|
+
"@harness-engineering/types": "0.6.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^22.0.0",
|