orch-code 0.1.1
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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- package/scripts/release.js +52 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
package review
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type Engine struct{}
|
|
11
|
+
|
|
12
|
+
func NewEngine() *Engine {
|
|
13
|
+
return &Engine{}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func (e *Engine) Evaluate(state *models.RunState, providerReview *models.ReviewResult) (*models.ReviewScorecard, *models.ReviewResult) {
|
|
17
|
+
if state == nil {
|
|
18
|
+
return nil, nil
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
requirementCoverage, requirementFindings := scoreRequirementCoverage(state)
|
|
22
|
+
scopeControl, scopeFindings := scoreScopeControl(state)
|
|
23
|
+
regressionRisk, regressionFindings := scoreRegressionRisk(state)
|
|
24
|
+
readability, readabilityFindings := scoreReadability(state)
|
|
25
|
+
maintainability, maintainabilityFindings := scoreMaintainability(state)
|
|
26
|
+
testAdequacy, testFindings := scoreTestAdequacy(state)
|
|
27
|
+
|
|
28
|
+
findings := make([]string, 0)
|
|
29
|
+
findings = append(findings, requirementFindings...)
|
|
30
|
+
findings = append(findings, scopeFindings...)
|
|
31
|
+
findings = append(findings, regressionFindings...)
|
|
32
|
+
findings = append(findings, readabilityFindings...)
|
|
33
|
+
findings = append(findings, maintainabilityFindings...)
|
|
34
|
+
findings = append(findings, testFindings...)
|
|
35
|
+
|
|
36
|
+
decision := models.ReviewAccept
|
|
37
|
+
average := float64(requirementCoverage+scopeControl+regressionRisk+readability+maintainability+testAdequacy) / 6.0
|
|
38
|
+
if requirementCoverage < 7 || scopeControl < 7 || testAdequacy < 7 || average < 7.5 {
|
|
39
|
+
decision = models.ReviewRevise
|
|
40
|
+
}
|
|
41
|
+
if hasFailedValidation(state.ValidationResults) {
|
|
42
|
+
decision = models.ReviewRevise
|
|
43
|
+
}
|
|
44
|
+
if providerReview != nil && providerReview.Decision == models.ReviewRevise {
|
|
45
|
+
decision = models.ReviewRevise
|
|
46
|
+
findings = append(findings, providerReview.Comments...)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
scorecard := &models.ReviewScorecard{
|
|
50
|
+
RequirementCoverage: requirementCoverage,
|
|
51
|
+
ScopeControl: scopeControl,
|
|
52
|
+
RegressionRisk: regressionRisk,
|
|
53
|
+
Readability: readability,
|
|
54
|
+
Maintainability: maintainability,
|
|
55
|
+
TestAdequacy: testAdequacy,
|
|
56
|
+
Decision: decision,
|
|
57
|
+
Findings: uniqueNonEmpty(findings),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
finalReview := &models.ReviewResult{
|
|
61
|
+
Decision: decision,
|
|
62
|
+
Comments: buildReviewComments(scorecard, providerReview, average),
|
|
63
|
+
Suggestions: buildReviewSuggestions(scorecard),
|
|
64
|
+
}
|
|
65
|
+
return scorecard, finalReview
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func scoreRequirementCoverage(state *models.RunState) (int, []string) {
|
|
69
|
+
score := 5
|
|
70
|
+
findings := []string{}
|
|
71
|
+
if state.Plan == nil || len(state.Plan.AcceptanceCriteria) == 0 {
|
|
72
|
+
return 2, []string{"Structured plan acceptance criteria are missing or incomplete."}
|
|
73
|
+
}
|
|
74
|
+
score += 2
|
|
75
|
+
if state.Patch != nil && len(state.Patch.Files) > 0 {
|
|
76
|
+
score += 1
|
|
77
|
+
} else {
|
|
78
|
+
findings = append(findings, "Patch does not contain concrete file changes for the planned task.")
|
|
79
|
+
}
|
|
80
|
+
if validationPassed(state.ValidationResults, "plan_compliance") {
|
|
81
|
+
score += 2
|
|
82
|
+
} else {
|
|
83
|
+
findings = append(findings, "Patch did not clearly satisfy plan compliance expectations.")
|
|
84
|
+
}
|
|
85
|
+
return clampScore(score), findings
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func scoreScopeControl(state *models.RunState) (int, []string) {
|
|
89
|
+
score := 5
|
|
90
|
+
findings := []string{}
|
|
91
|
+
if validationPassed(state.ValidationResults, "scope_compliance") {
|
|
92
|
+
score += 3
|
|
93
|
+
} else {
|
|
94
|
+
score = 2
|
|
95
|
+
findings = append(findings, "Scope compliance gate did not pass cleanly.")
|
|
96
|
+
}
|
|
97
|
+
if validationPassed(state.ValidationResults, "patch_hygiene") {
|
|
98
|
+
score += 2
|
|
99
|
+
} else {
|
|
100
|
+
findings = append(findings, "Patch hygiene gate indicates the diff may be too risky or malformed.")
|
|
101
|
+
}
|
|
102
|
+
return clampScore(score), findings
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func scoreRegressionRisk(state *models.RunState) (int, []string) {
|
|
106
|
+
score := 7
|
|
107
|
+
findings := []string{}
|
|
108
|
+
if state.TaskBrief != nil && state.TaskBrief.RiskLevel == models.RiskHigh {
|
|
109
|
+
score--
|
|
110
|
+
findings = append(findings, "Task is classified as high-risk and needs extra caution.")
|
|
111
|
+
}
|
|
112
|
+
if strings.TrimSpace(state.TestResults) == "" {
|
|
113
|
+
score -= 2
|
|
114
|
+
findings = append(findings, "Test output is empty, which weakens regression confidence.")
|
|
115
|
+
}
|
|
116
|
+
if hasFailedValidation(state.ValidationResults) {
|
|
117
|
+
score -= 3
|
|
118
|
+
findings = append(findings, "One or more validation gates failed earlier in the pipeline.")
|
|
119
|
+
}
|
|
120
|
+
if state.Retries.Testing > 0 || state.Retries.Validation > 0 {
|
|
121
|
+
score--
|
|
122
|
+
findings = append(findings, "Retry activity indicates prior instability before review.")
|
|
123
|
+
}
|
|
124
|
+
return clampScore(score), findings
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func scoreReadability(state *models.RunState) (int, []string) {
|
|
128
|
+
score := 8
|
|
129
|
+
findings := []string{}
|
|
130
|
+
if state.Patch == nil {
|
|
131
|
+
return 3, []string{"No patch is available to assess readability."}
|
|
132
|
+
}
|
|
133
|
+
if len(state.Patch.Files) > 4 {
|
|
134
|
+
score -= 2
|
|
135
|
+
findings = append(findings, "Patch touches many files, making review and readability harder.")
|
|
136
|
+
}
|
|
137
|
+
lineCount := diffLineCount(state.Patch)
|
|
138
|
+
if lineCount > 300 {
|
|
139
|
+
score -= 3
|
|
140
|
+
findings = append(findings, "Patch is large enough to reduce readability confidence.")
|
|
141
|
+
} else if lineCount > 120 {
|
|
142
|
+
score -= 1
|
|
143
|
+
}
|
|
144
|
+
return clampScore(score), findings
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func scoreMaintainability(state *models.RunState) (int, []string) {
|
|
148
|
+
score := 8
|
|
149
|
+
findings := []string{}
|
|
150
|
+
if state.Plan == nil {
|
|
151
|
+
score = 4
|
|
152
|
+
findings = append(findings, "Structured plan is missing, so maintainability alignment is unclear.")
|
|
153
|
+
}
|
|
154
|
+
if state.ExecutionContract == nil {
|
|
155
|
+
score -= 2
|
|
156
|
+
findings = append(findings, "Execution contract is missing, reducing maintainability guarantees.")
|
|
157
|
+
}
|
|
158
|
+
if len(state.UnresolvedFailures) > 0 {
|
|
159
|
+
score -= 2
|
|
160
|
+
findings = append(findings, "There are unresolved failures recorded in the run state.")
|
|
161
|
+
}
|
|
162
|
+
return clampScore(score), findings
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func scoreTestAdequacy(state *models.RunState) (int, []string) {
|
|
166
|
+
score := 4
|
|
167
|
+
findings := []string{}
|
|
168
|
+
if state.Plan == nil || len(state.Plan.TestRequirements) == 0 {
|
|
169
|
+
findings = append(findings, "Plan does not define explicit test requirements.")
|
|
170
|
+
}
|
|
171
|
+
if strings.TrimSpace(state.TestResults) != "" {
|
|
172
|
+
score = 8
|
|
173
|
+
} else {
|
|
174
|
+
findings = append(findings, "No concrete test output was recorded for the review step.")
|
|
175
|
+
}
|
|
176
|
+
if state.Retries.Testing > 0 {
|
|
177
|
+
score--
|
|
178
|
+
findings = append(findings, "Tests required retries before review acceptance could be considered.")
|
|
179
|
+
}
|
|
180
|
+
return clampScore(score), findings
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func buildReviewComments(scorecard *models.ReviewScorecard, providerReview *models.ReviewResult, average float64) []string {
|
|
184
|
+
comments := []string{
|
|
185
|
+
fmt.Sprintf("Review scorecard: requirement=%d scope=%d regression=%d readability=%d maintainability=%d test=%d avg=%.1f", scorecard.RequirementCoverage, scorecard.ScopeControl, scorecard.RegressionRisk, scorecard.Readability, scorecard.Maintainability, scorecard.TestAdequacy, average),
|
|
186
|
+
}
|
|
187
|
+
if providerReview != nil {
|
|
188
|
+
comments = append(comments, providerReview.Comments...)
|
|
189
|
+
}
|
|
190
|
+
comments = append(comments, scorecard.Findings...)
|
|
191
|
+
return uniqueNonEmpty(comments)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func buildReviewSuggestions(scorecard *models.ReviewScorecard) []string {
|
|
195
|
+
if scorecard == nil || scorecard.Decision != models.ReviewRevise {
|
|
196
|
+
return []string{}
|
|
197
|
+
}
|
|
198
|
+
suggestions := []string{}
|
|
199
|
+
for _, finding := range scorecard.Findings {
|
|
200
|
+
suggestions = append(suggestions, "Address review finding: "+finding)
|
|
201
|
+
}
|
|
202
|
+
if len(suggestions) == 0 {
|
|
203
|
+
suggestions = append(suggestions, "Improve the patch so that all review rubric categories meet the acceptance threshold.")
|
|
204
|
+
}
|
|
205
|
+
return uniqueNonEmpty(suggestions)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func validationPassed(results []models.ValidationResult, name string) bool {
|
|
209
|
+
for _, result := range results {
|
|
210
|
+
if result.Name == name {
|
|
211
|
+
return result.Status == models.ValidationPass
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func hasFailedValidation(results []models.ValidationResult) bool {
|
|
218
|
+
for _, result := range results {
|
|
219
|
+
if result.Status == models.ValidationFail {
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func diffLineCount(patch *models.Patch) int {
|
|
227
|
+
if patch == nil {
|
|
228
|
+
return 0
|
|
229
|
+
}
|
|
230
|
+
count := 0
|
|
231
|
+
for _, line := range strings.Split(patch.RawDiff, "\n") {
|
|
232
|
+
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
|
|
233
|
+
if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") {
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
count++
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return count
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
func clampScore(score int) int {
|
|
243
|
+
if score < 0 {
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
if score > 10 {
|
|
247
|
+
return 10
|
|
248
|
+
}
|
|
249
|
+
return score
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func uniqueNonEmpty(values []string) []string {
|
|
253
|
+
result := make([]string, 0, len(values))
|
|
254
|
+
seen := map[string]struct{}{}
|
|
255
|
+
for _, value := range values {
|
|
256
|
+
trimmed := strings.TrimSpace(value)
|
|
257
|
+
if trimmed == "" {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if _, ok := seen[trimmed]; ok {
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
seen[trimmed] = struct{}{}
|
|
264
|
+
result = append(result, trimmed)
|
|
265
|
+
}
|
|
266
|
+
return result
|
|
267
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package review
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestEvaluateAcceptsHealthyRun(t *testing.T) {
|
|
11
|
+
engine := NewEngine()
|
|
12
|
+
state := &models.RunState{
|
|
13
|
+
Task: models.Task{ID: "task-1", Description: "fix auth bug", CreatedAt: time.Now()},
|
|
14
|
+
TaskBrief: &models.TaskBrief{TaskID: "task-1", TaskType: models.TaskTypeBugfix, RiskLevel: models.RiskMedium},
|
|
15
|
+
Plan: &models.Plan{
|
|
16
|
+
TaskID: "task-1",
|
|
17
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Bug no longer occurs."}},
|
|
18
|
+
TestRequirements: []string{"Run go test ./..."},
|
|
19
|
+
},
|
|
20
|
+
ExecutionContract: &models.ExecutionContract{AllowedFiles: []string{"internal/auth/service.go"}},
|
|
21
|
+
Patch: &models.Patch{
|
|
22
|
+
RawDiff: "diff --git a/internal/auth/service.go b/internal/auth/service.go\n--- a/internal/auth/service.go\n+++ b/internal/auth/service.go\n@@ -1 +1 @@\n-old\n+new\n",
|
|
23
|
+
Files: []models.PatchFile{{Path: "internal/auth/service.go"}},
|
|
24
|
+
},
|
|
25
|
+
ValidationResults: []models.ValidationResult{
|
|
26
|
+
{Name: "patch_hygiene", Status: models.ValidationPass},
|
|
27
|
+
{Name: "scope_compliance", Status: models.ValidationPass},
|
|
28
|
+
{Name: "plan_compliance", Status: models.ValidationPass},
|
|
29
|
+
},
|
|
30
|
+
TestResults: "ok github.com/example/project/auth 0.100s",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
scorecard, review := engine.Evaluate(state, nil)
|
|
34
|
+
if scorecard == nil || review == nil {
|
|
35
|
+
t.Fatalf("expected scorecard and review")
|
|
36
|
+
}
|
|
37
|
+
if scorecard.Decision != models.ReviewAccept {
|
|
38
|
+
t.Fatalf("expected accept decision, got %s", scorecard.Decision)
|
|
39
|
+
}
|
|
40
|
+
if review.Decision != models.ReviewAccept {
|
|
41
|
+
t.Fatalf("expected accept review, got %s", review.Decision)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestEvaluateRevisesWhenScopeFails(t *testing.T) {
|
|
46
|
+
engine := NewEngine()
|
|
47
|
+
state := &models.RunState{
|
|
48
|
+
Task: models.Task{ID: "task-2", Description: "feature task", CreatedAt: time.Now()},
|
|
49
|
+
TaskBrief: &models.TaskBrief{TaskID: "task-2", TaskType: models.TaskTypeFeature, RiskLevel: models.RiskMedium},
|
|
50
|
+
Plan: &models.Plan{
|
|
51
|
+
TaskID: "task-2",
|
|
52
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Feature works."}},
|
|
53
|
+
TestRequirements: []string{"Run tests"},
|
|
54
|
+
},
|
|
55
|
+
Patch: &models.Patch{Files: []models.PatchFile{{Path: "internal/feature/service.go"}}},
|
|
56
|
+
ValidationResults: []models.ValidationResult{
|
|
57
|
+
{Name: "scope_compliance", Status: models.ValidationFail, Summary: "out of scope"},
|
|
58
|
+
{Name: "plan_compliance", Status: models.ValidationFail, Summary: "missing required file"},
|
|
59
|
+
},
|
|
60
|
+
TestResults: "ok",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
scorecard, review := engine.Evaluate(state, nil)
|
|
64
|
+
if scorecard.Decision != models.ReviewRevise {
|
|
65
|
+
t.Fatalf("expected revise scorecard decision, got %s", scorecard.Decision)
|
|
66
|
+
}
|
|
67
|
+
if review.Decision != models.ReviewRevise {
|
|
68
|
+
t.Fatalf("expected revise review decision, got %s", review.Decision)
|
|
69
|
+
}
|
|
70
|
+
if len(scorecard.Findings) == 0 {
|
|
71
|
+
t.Fatalf("expected findings for revise decision")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestEvaluateRespectsProviderReviseSignal(t *testing.T) {
|
|
76
|
+
engine := NewEngine()
|
|
77
|
+
state := &models.RunState{
|
|
78
|
+
Task: models.Task{ID: "task-3", Description: "review provider", CreatedAt: time.Now()},
|
|
79
|
+
TaskBrief: &models.TaskBrief{TaskID: "task-3", TaskType: models.TaskTypeFeature, RiskLevel: models.RiskLow},
|
|
80
|
+
Plan: &models.Plan{
|
|
81
|
+
TaskID: "task-3",
|
|
82
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Feature works."}},
|
|
83
|
+
TestRequirements: []string{"Run tests"},
|
|
84
|
+
},
|
|
85
|
+
ExecutionContract: &models.ExecutionContract{AllowedFiles: []string{"internal/feature/service.go"}},
|
|
86
|
+
Patch: &models.Patch{Files: []models.PatchFile{{Path: "internal/feature/service.go"}}},
|
|
87
|
+
ValidationResults: []models.ValidationResult{
|
|
88
|
+
{Name: "patch_hygiene", Status: models.ValidationPass},
|
|
89
|
+
{Name: "scope_compliance", Status: models.ValidationPass},
|
|
90
|
+
{Name: "plan_compliance", Status: models.ValidationPass},
|
|
91
|
+
},
|
|
92
|
+
TestResults: "ok",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
providerReview := &models.ReviewResult{Decision: models.ReviewRevise, Comments: []string{"revise: missing edge case"}}
|
|
96
|
+
scorecard, review := engine.Evaluate(state, providerReview)
|
|
97
|
+
if scorecard.Decision != models.ReviewRevise {
|
|
98
|
+
t.Fatalf("expected provider revise to force revise")
|
|
99
|
+
}
|
|
100
|
+
if review.Decision != models.ReviewRevise {
|
|
101
|
+
t.Fatalf("expected final review revise")
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package runstore
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
12
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const (
|
|
16
|
+
latestRunFile = "latest-run-id"
|
|
17
|
+
latestPatch = "latest.patch"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func SaveRunState(repoRoot string, state *models.RunState) error {
|
|
21
|
+
if state == nil {
|
|
22
|
+
return fmt.Errorf("run state cannot be nil")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if err := config.EnsureOrchDir(repoRoot); err != nil {
|
|
26
|
+
return err
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
data, err := json.MarshalIndent(state, "", " ")
|
|
30
|
+
if err != nil {
|
|
31
|
+
return fmt.Errorf("marshal run state: %w", err)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
runsDir := filepath.Join(repoRoot, config.OrchDir, config.RunsDir)
|
|
35
|
+
statePath := filepath.Join(runsDir, state.ID+".state")
|
|
36
|
+
if err := os.WriteFile(statePath, data, 0o644); err != nil {
|
|
37
|
+
return fmt.Errorf("write run state: %w", err)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
latestRunPath := filepath.Join(repoRoot, config.OrchDir, latestRunFile)
|
|
41
|
+
if err := os.WriteFile(latestRunPath, []byte(state.ID), 0o644); err != nil {
|
|
42
|
+
return fmt.Errorf("write latest run id: %w", err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
patchPath := filepath.Join(repoRoot, config.OrchDir, latestPatch)
|
|
46
|
+
if state.Patch != nil && strings.TrimSpace(state.Patch.RawDiff) != "" {
|
|
47
|
+
if err := os.WriteFile(patchPath, []byte(state.Patch.RawDiff), 0o644); err != nil {
|
|
48
|
+
return fmt.Errorf("write latest patch: %w", err)
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
if err := os.Remove(patchPath); err != nil && !os.IsNotExist(err) {
|
|
52
|
+
return fmt.Errorf("remove stale latest patch: %w", err)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func LoadLatestRunState(repoRoot string) (*models.RunState, error) {
|
|
60
|
+
latestRunPath := filepath.Join(repoRoot, config.OrchDir, latestRunFile)
|
|
61
|
+
runIDBytes, err := os.ReadFile(latestRunPath)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return nil, fmt.Errorf("read latest run id: %w", err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
runID := strings.TrimSpace(string(runIDBytes))
|
|
67
|
+
if runID == "" {
|
|
68
|
+
return nil, fmt.Errorf("latest run id is empty")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return LoadRunState(repoRoot, runID)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func LoadRunState(repoRoot, runID string) (*models.RunState, error) {
|
|
75
|
+
runID = strings.TrimSpace(runID)
|
|
76
|
+
if runID == "" {
|
|
77
|
+
return nil, fmt.Errorf("run id is required")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
statePath := filepath.Join(repoRoot, config.OrchDir, config.RunsDir, runID+".state")
|
|
81
|
+
data, err := os.ReadFile(statePath)
|
|
82
|
+
if err != nil {
|
|
83
|
+
return nil, fmt.Errorf("read run state: %w", err)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var state models.RunState
|
|
87
|
+
if err := json.Unmarshal(data, &state); err != nil {
|
|
88
|
+
return nil, fmt.Errorf("unmarshal run state: %w", err)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return &state, nil
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func ListRunStates(repoRoot string, limit int) ([]*models.RunState, error) {
|
|
95
|
+
if err := config.EnsureOrchDir(repoRoot); err != nil {
|
|
96
|
+
return nil, err
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
runsDir := filepath.Join(repoRoot, config.OrchDir, config.RunsDir)
|
|
100
|
+
entries, err := os.ReadDir(runsDir)
|
|
101
|
+
if err != nil {
|
|
102
|
+
return nil, fmt.Errorf("read runs dir: %w", err)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
states := make([]*models.RunState, 0, len(entries))
|
|
106
|
+
for _, entry := range entries {
|
|
107
|
+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".state") {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
runID := strings.TrimSuffix(entry.Name(), ".state")
|
|
111
|
+
state, err := LoadRunState(repoRoot, runID)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
|
114
|
+
}
|
|
115
|
+
states = append(states, state)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
sort.SliceStable(states, func(i, j int) bool {
|
|
119
|
+
return states[i].StartedAt.After(states[j].StartedAt)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if limit > 0 && len(states) > limit {
|
|
123
|
+
states = states[:limit]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return states, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func LoadLatestPatch(repoRoot string) (string, error) {
|
|
130
|
+
patchPath := filepath.Join(repoRoot, config.OrchDir, latestPatch)
|
|
131
|
+
data, err := os.ReadFile(patchPath)
|
|
132
|
+
if err != nil {
|
|
133
|
+
return "", fmt.Errorf("read latest patch: %w", err)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return string(data), nil
|
|
137
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package runstore
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestListRunStatesSortedAndLimited(t *testing.T) {
|
|
11
|
+
repoRoot := t.TempDir()
|
|
12
|
+
now := time.Now().UTC()
|
|
13
|
+
|
|
14
|
+
states := []*models.RunState{
|
|
15
|
+
{ID: "run-older", Task: models.Task{ID: "task-1", Description: "older", CreatedAt: now}, Status: models.StatusCompleted, StartedAt: now.Add(-2 * time.Hour)},
|
|
16
|
+
{ID: "run-newer", Task: models.Task{ID: "task-2", Description: "newer", CreatedAt: now}, Status: models.StatusFailed, StartedAt: now.Add(-1 * time.Hour)},
|
|
17
|
+
}
|
|
18
|
+
for _, state := range states {
|
|
19
|
+
if err := SaveRunState(repoRoot, state); err != nil {
|
|
20
|
+
t.Fatalf("save run state %s: %v", state.ID, err)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
loaded, err := ListRunStates(repoRoot, 1)
|
|
25
|
+
if err != nil {
|
|
26
|
+
t.Fatalf("list run states: %v", err)
|
|
27
|
+
}
|
|
28
|
+
if len(loaded) != 1 {
|
|
29
|
+
t.Fatalf("expected 1 run, got %d", len(loaded))
|
|
30
|
+
}
|
|
31
|
+
if loaded[0].ID != "run-newer" {
|
|
32
|
+
t.Fatalf("expected newest run first, got %s", loaded[0].ID)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestLoadRunState(t *testing.T) {
|
|
37
|
+
repoRoot := t.TempDir()
|
|
38
|
+
state := &models.RunState{
|
|
39
|
+
ID: "run-1",
|
|
40
|
+
Task: models.Task{ID: "task-1", Description: "demo", CreatedAt: time.Now()},
|
|
41
|
+
Status: models.StatusCompleted,
|
|
42
|
+
StartedAt: time.Now(),
|
|
43
|
+
Confidence: &models.ConfidenceReport{Score: 0.88, Band: "high"},
|
|
44
|
+
}
|
|
45
|
+
if err := SaveRunState(repoRoot, state); err != nil {
|
|
46
|
+
t.Fatalf("save run state: %v", err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
loaded, err := LoadRunState(repoRoot, state.ID)
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatalf("load run state: %v", err)
|
|
52
|
+
}
|
|
53
|
+
if loaded.ID != state.ID {
|
|
54
|
+
t.Fatalf("unexpected run id: got=%s want=%s", loaded.ID, state.ID)
|
|
55
|
+
}
|
|
56
|
+
if loaded.Confidence == nil || loaded.Confidence.Band != "high" {
|
|
57
|
+
t.Fatalf("expected confidence report to roundtrip")
|
|
58
|
+
}
|
|
59
|
+
}
|