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.
Files changed (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. 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
+ }