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,413 @@
1
+ package storage
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/furkanbeydemir/orch/internal/models"
9
+ )
10
+
11
+ func TestProjectAndDefaultSessionBootstrap(t *testing.T) {
12
+ repoRoot := t.TempDir()
13
+
14
+ store, err := Open(repoRoot)
15
+ if err != nil {
16
+ t.Fatalf("open store: %v", err)
17
+ }
18
+ defer store.Close()
19
+
20
+ projectID, err := store.GetOrCreateProject()
21
+ if err != nil {
22
+ t.Fatalf("get or create project: %v", err)
23
+ }
24
+
25
+ session, err := store.EnsureDefaultSession(projectID)
26
+ if err != nil {
27
+ t.Fatalf("ensure default session: %v", err)
28
+ }
29
+ if session.Name != "default" {
30
+ t.Fatalf("unexpected default session name: %s", session.Name)
31
+ }
32
+
33
+ active, err := store.GetActiveSession(projectID)
34
+ if err != nil {
35
+ t.Fatalf("get active session: %v", err)
36
+ }
37
+ if active.ID != session.ID {
38
+ t.Fatalf("active session mismatch: got=%s want=%s", active.ID, session.ID)
39
+ }
40
+ }
41
+
42
+ func TestSessionLifecycle(t *testing.T) {
43
+ repoRoot := t.TempDir()
44
+
45
+ store, err := Open(repoRoot)
46
+ if err != nil {
47
+ t.Fatalf("open store: %v", err)
48
+ }
49
+ defer store.Close()
50
+
51
+ projectID, err := store.GetOrCreateProject()
52
+ if err != nil {
53
+ t.Fatalf("get or create project: %v", err)
54
+ }
55
+
56
+ _, err = store.EnsureDefaultSession(projectID)
57
+ if err != nil {
58
+ t.Fatalf("ensure default session: %v", err)
59
+ }
60
+
61
+ feature, err := store.CreateSession(projectID, "feature-x")
62
+ if err != nil {
63
+ t.Fatalf("create session: %v", err)
64
+ }
65
+
66
+ selected, err := store.SelectSession(projectID, "feature-x")
67
+ if err != nil {
68
+ t.Fatalf("select session: %v", err)
69
+ }
70
+ if selected.ID != feature.ID {
71
+ t.Fatalf("selected session mismatch: got=%s want=%s", selected.ID, feature.ID)
72
+ }
73
+
74
+ if err := store.CloseSession(projectID, "feature-x"); err != nil {
75
+ t.Fatalf("close session: %v", err)
76
+ }
77
+
78
+ _, err = store.SelectSession(projectID, "feature-x")
79
+ if !errors.Is(err, ErrSessionClosed) {
80
+ t.Fatalf("expected closed session error, got: %v", err)
81
+ }
82
+ }
83
+
84
+ func TestSaveRunState(t *testing.T) {
85
+ repoRoot := t.TempDir()
86
+
87
+ store, err := Open(repoRoot)
88
+ if err != nil {
89
+ t.Fatalf("open store: %v", err)
90
+ }
91
+ defer store.Close()
92
+
93
+ projectID, err := store.GetOrCreateProject()
94
+ if err != nil {
95
+ t.Fatalf("get or create project: %v", err)
96
+ }
97
+ session, err := store.EnsureDefaultSession(projectID)
98
+ if err != nil {
99
+ t.Fatalf("ensure default session: %v", err)
100
+ }
101
+
102
+ state := &models.RunState{
103
+ ID: "run-test-1",
104
+ ProjectID: projectID,
105
+ SessionID: session.ID,
106
+ Task: models.Task{
107
+ ID: "task-1",
108
+ Description: "save run state",
109
+ CreatedAt: time.Now(),
110
+ },
111
+ TaskBrief: &models.TaskBrief{
112
+ TaskID: "task-1",
113
+ UserRequest: "save run state",
114
+ NormalizedGoal: "Address task: save run state",
115
+ TaskType: models.TaskTypeChore,
116
+ RiskLevel: models.RiskLow,
117
+ },
118
+ Plan: &models.Plan{
119
+ TaskID: "task-1",
120
+ Summary: "Address task: save run state",
121
+ TaskType: models.TaskTypeChore,
122
+ RiskLevel: models.RiskLow,
123
+ Steps: []models.PlanStep{{Order: 1, Description: "Persist the run state."}},
124
+ },
125
+ ExecutionContract: &models.ExecutionContract{
126
+ TaskID: "task-1",
127
+ AllowedFiles: []string{"internal/storage/storage.go"},
128
+ PatchBudget: models.PatchBudget{MaxFiles: 1, MaxChangedLines: 20},
129
+ },
130
+ Patch: &models.Patch{
131
+ TaskID: "task-1",
132
+ Files: []models.PatchFile{{
133
+ Path: "README.md",
134
+ Status: "modified",
135
+ Diff: "@@ -1 +1 @@\n-old\n+new\n",
136
+ }},
137
+ RawDiff: "diff --git a/README.md b/README.md\nindex 1111111..2222222 100644\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n",
138
+ },
139
+ ValidationResults: []models.ValidationResult{{
140
+ Name: "task_brief_valid",
141
+ Stage: "planning",
142
+ Status: models.ValidationPass,
143
+ Severity: models.SeverityLow,
144
+ Summary: "task brief persisted",
145
+ }},
146
+ RetryDirective: &models.RetryDirective{
147
+ Stage: "validation",
148
+ Attempt: 1,
149
+ FailedGates: []string{"plan_compliance"},
150
+ Instructions: []string{"Update the required file."},
151
+ },
152
+ Confidence: &models.ConfidenceReport{
153
+ Score: 0.74,
154
+ Band: "medium",
155
+ Reasons: []string{"validation and planning artifacts are present"},
156
+ },
157
+ TestFailures: []models.TestFailure{{
158
+ Code: "test_assertion_failure",
159
+ Summary: "expected 200 got 500",
160
+ }},
161
+ Status: models.StatusCompleted,
162
+ StartedAt: time.Now(),
163
+ }
164
+
165
+ if err := store.SaveRunState(state); err != nil {
166
+ t.Fatalf("save run state: %v", err)
167
+ }
168
+
169
+ runs, err := store.ListRunsBySession(session.ID, 10)
170
+ if err != nil {
171
+ t.Fatalf("list runs by session: %v", err)
172
+ }
173
+ if len(runs) == 0 {
174
+ t.Fatalf("expected at least one run record")
175
+ }
176
+ if runs[0].ID != state.ID {
177
+ t.Fatalf("unexpected run id: got=%s want=%s", runs[0].ID, state.ID)
178
+ }
179
+
180
+ filteredByStatus, err := store.ListRunsBySessionFiltered(session.ID, 10, string(models.StatusCompleted), "")
181
+ if err != nil {
182
+ t.Fatalf("filter runs by status: %v", err)
183
+ }
184
+ if len(filteredByStatus) != 1 {
185
+ t.Fatalf("expected one completed run, got=%d", len(filteredByStatus))
186
+ }
187
+
188
+ filteredByText, err := store.ListRunsBySessionFiltered(session.ID, 10, "", "save run")
189
+ if err != nil {
190
+ t.Fatalf("filter runs by task text: %v", err)
191
+ }
192
+ if len(filteredByText) != 1 {
193
+ t.Fatalf("expected one text-matched run, got=%d", len(filteredByText))
194
+ }
195
+
196
+ latestState, err := store.GetLatestRunStateBySession(session.ID)
197
+ if err != nil {
198
+ t.Fatalf("get latest run state by session: %v", err)
199
+ }
200
+ if latestState == nil || latestState.ID != state.ID {
201
+ t.Fatalf("unexpected latest run state: %+v", latestState)
202
+ }
203
+
204
+ loadedState, err := store.GetRunState(projectID, state.ID)
205
+ if err != nil {
206
+ t.Fatalf("get run state by id: %v", err)
207
+ }
208
+ if loadedState == nil || loadedState.Task.Description != "save run state" {
209
+ t.Fatalf("unexpected loaded run state: %+v", loadedState)
210
+ }
211
+
212
+ projectStates, err := store.ListRunStatesByProject(projectID, 10)
213
+ if err != nil {
214
+ t.Fatalf("list run states by project: %v", err)
215
+ }
216
+ if len(projectStates) != 1 {
217
+ t.Fatalf("expected one project state, got %d", len(projectStates))
218
+ }
219
+
220
+ patchText, err := store.LoadLatestPatchBySession(session.ID)
221
+ if err != nil {
222
+ t.Fatalf("load latest patch by session: %v", err)
223
+ }
224
+ if patchText != state.Patch.RawDiff {
225
+ t.Fatalf("unexpected patch text loaded")
226
+ }
227
+ }
228
+
229
+ func TestSessionMessagePartLifecycle(t *testing.T) {
230
+ repoRoot := t.TempDir()
231
+
232
+ store, err := Open(repoRoot)
233
+ if err != nil {
234
+ t.Fatalf("open store: %v", err)
235
+ }
236
+ defer store.Close()
237
+
238
+ projectID, err := store.GetOrCreateProject()
239
+ if err != nil {
240
+ t.Fatalf("get or create project: %v", err)
241
+ }
242
+ session, err := store.EnsureDefaultSession(projectID)
243
+ if err != nil {
244
+ t.Fatalf("ensure default session: %v", err)
245
+ }
246
+
247
+ createdMsg, createdParts, err := store.CreateMessageWithParts(SessionMessage{
248
+ SessionID: session.ID,
249
+ Role: "user",
250
+ ProviderID: "openai",
251
+ ModelID: "gpt-5.3-codex",
252
+ }, []SessionPart{{
253
+ Type: "text",
254
+ Payload: `{"text":"selam"}`,
255
+ }})
256
+ if err != nil {
257
+ t.Fatalf("create message with parts: %v", err)
258
+ }
259
+ if createdMsg.ID == "" {
260
+ t.Fatalf("expected message id")
261
+ }
262
+ if len(createdParts) != 1 {
263
+ t.Fatalf("expected one part, got %d", len(createdParts))
264
+ }
265
+ if createdParts[0].MessageID != createdMsg.ID {
266
+ t.Fatalf("unexpected part message id: got=%s want=%s", createdParts[0].MessageID, createdMsg.ID)
267
+ }
268
+
269
+ messages, err := store.ListSessionMessages(session.ID, 10)
270
+ if err != nil {
271
+ t.Fatalf("list session messages: %v", err)
272
+ }
273
+ if len(messages) != 1 {
274
+ t.Fatalf("expected one message, got %d", len(messages))
275
+ }
276
+ if messages[0].Role != "user" {
277
+ t.Fatalf("unexpected message role: %s", messages[0].Role)
278
+ }
279
+
280
+ parts, err := store.ListSessionParts(createdMsg.ID)
281
+ if err != nil {
282
+ t.Fatalf("list session parts: %v", err)
283
+ }
284
+ if len(parts) != 1 {
285
+ t.Fatalf("expected one part, got %d", len(parts))
286
+ }
287
+ if parts[0].Type != "text" {
288
+ t.Fatalf("unexpected part type: %s", parts[0].Type)
289
+ }
290
+ if parts[0].Payload == "" {
291
+ t.Fatalf("expected payload content")
292
+ }
293
+ }
294
+
295
+ func TestSessionSummaryAndMetrics(t *testing.T) {
296
+ repoRoot := t.TempDir()
297
+
298
+ store, err := Open(repoRoot)
299
+ if err != nil {
300
+ t.Fatalf("open store: %v", err)
301
+ }
302
+ defer store.Close()
303
+
304
+ projectID, err := store.GetOrCreateProject()
305
+ if err != nil {
306
+ t.Fatalf("get or create project: %v", err)
307
+ }
308
+ session, err := store.EnsureDefaultSession(projectID)
309
+ if err != nil {
310
+ t.Fatalf("ensure default session: %v", err)
311
+ }
312
+
313
+ if err := store.UpsertSessionSummary(session.ID, "## Goal\nShip session-only runtime"); err != nil {
314
+ t.Fatalf("upsert session summary: %v", err)
315
+ }
316
+ summary, err := store.GetSessionSummary(session.ID)
317
+ if err != nil {
318
+ t.Fatalf("get session summary: %v", err)
319
+ }
320
+ if summary == nil {
321
+ t.Fatalf("expected session summary")
322
+ }
323
+ if summary.SummaryText == "" {
324
+ t.Fatalf("expected summary text")
325
+ }
326
+
327
+ err = store.UpsertSessionMetrics(SessionMetrics{
328
+ SessionID: session.ID,
329
+ InputTokens: 120,
330
+ OutputTokens: 35,
331
+ TotalCost: 0.014,
332
+ TurnCount: 2,
333
+ LastMessageID: "msg-123",
334
+ })
335
+ if err != nil {
336
+ t.Fatalf("upsert session metrics: %v", err)
337
+ }
338
+ metrics, err := store.GetSessionMetrics(session.ID)
339
+ if err != nil {
340
+ t.Fatalf("get session metrics: %v", err)
341
+ }
342
+ if metrics == nil {
343
+ t.Fatalf("expected session metrics")
344
+ }
345
+ if metrics.InputTokens != 120 || metrics.OutputTokens != 35 || metrics.TurnCount != 2 {
346
+ t.Fatalf("unexpected metrics payload: %+v", metrics)
347
+ }
348
+ }
349
+
350
+ func TestCompactSessionParts(t *testing.T) {
351
+ repoRoot := t.TempDir()
352
+
353
+ store, err := Open(repoRoot)
354
+ if err != nil {
355
+ t.Fatalf("open store: %v", err)
356
+ }
357
+ defer store.Close()
358
+
359
+ projectID, err := store.GetOrCreateProject()
360
+ if err != nil {
361
+ t.Fatalf("get project: %v", err)
362
+ }
363
+ session, err := store.EnsureDefaultSession(projectID)
364
+ if err != nil {
365
+ t.Fatalf("ensure default session: %v", err)
366
+ }
367
+
368
+ for i := 0; i < 4; i++ {
369
+ msg, _, createErr := store.CreateMessageWithParts(SessionMessage{
370
+ SessionID: session.ID,
371
+ Role: "user",
372
+ }, []SessionPart{{Type: "text", Payload: `{"text":"payload"}`}})
373
+ if createErr != nil {
374
+ t.Fatalf("create message %d: %v", i, createErr)
375
+ }
376
+ parts, listErr := store.ListSessionParts(msg.ID)
377
+ if listErr != nil || len(parts) != 1 {
378
+ t.Fatalf("list parts for %s: %v", msg.ID, listErr)
379
+ }
380
+ }
381
+
382
+ affected, err := store.CompactSessionParts(session.ID, 1)
383
+ if err != nil {
384
+ t.Fatalf("compact session parts: %v", err)
385
+ }
386
+ if affected == 0 {
387
+ t.Fatalf("expected compacted rows")
388
+ }
389
+
390
+ messages, err := store.ListSessionMessages(session.ID, 10)
391
+ if err != nil {
392
+ t.Fatalf("list session messages: %v", err)
393
+ }
394
+ if len(messages) != 4 {
395
+ t.Fatalf("expected 4 messages, got %d", len(messages))
396
+ }
397
+
398
+ compactedCount := 0
399
+ for _, message := range messages {
400
+ parts, partErr := store.ListSessionParts(message.ID)
401
+ if partErr != nil {
402
+ t.Fatalf("list parts: %v", partErr)
403
+ }
404
+ for _, part := range parts {
405
+ if part.Compacted {
406
+ compactedCount++
407
+ }
408
+ }
409
+ }
410
+ if compactedCount == 0 {
411
+ t.Fatalf("expected some compacted parts")
412
+ }
413
+ }
@@ -0,0 +1,80 @@
1
+ package testingx
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "github.com/furkanbeydemir/orch/internal/models"
7
+ )
8
+
9
+ type Classifier struct{}
10
+
11
+ func NewClassifier() *Classifier {
12
+ return &Classifier{}
13
+ }
14
+
15
+ func (c *Classifier) Classify(output, errText string) []models.TestFailure {
16
+ combined := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(output), strings.TrimSpace(errText)}, "\n"))
17
+ if combined == "" {
18
+ return []models.TestFailure{{
19
+ Code: "test_setup_failure",
20
+ Summary: "test command failed without output",
21
+ Details: []string{"No test output was captured from the failed command."},
22
+ }}
23
+ }
24
+
25
+ lines := splitNonEmptyLines(combined)
26
+ lower := strings.ToLower(combined)
27
+
28
+ switch {
29
+ case strings.Contains(lower, "timed out") || strings.Contains(lower, "timeout"):
30
+ return []models.TestFailure{{
31
+ Code: "test_timeout",
32
+ Summary: "test command timed out",
33
+ Details: lines,
34
+ }}
35
+ case strings.Contains(lower, "panic:") || strings.Contains(lower, "segmentation fault"):
36
+ return []models.TestFailure{{
37
+ Code: "test_setup_failure",
38
+ Summary: "test runtime crashed or panicked",
39
+ Details: lines,
40
+ }}
41
+ case strings.Contains(lower, "no test files"):
42
+ return []models.TestFailure{{
43
+ Code: "missing_required_tests",
44
+ Summary: "required tests appear to be missing",
45
+ Details: lines,
46
+ }}
47
+ case strings.Contains(lower, "assert") || strings.Contains(lower, "expected") || strings.Contains(lower, "--- fail") || strings.Contains(lower, "not equal"):
48
+ return []models.TestFailure{{
49
+ Code: "test_assertion_failure",
50
+ Summary: "test assertions failed",
51
+ Details: lines,
52
+ }}
53
+ case strings.Contains(lower, "flake") || strings.Contains(lower, "flaky"):
54
+ return []models.TestFailure{{
55
+ Code: "flaky_test_suspected",
56
+ Summary: "test output suggests flaky behavior",
57
+ Details: lines,
58
+ Flaky: true,
59
+ }}
60
+ default:
61
+ return []models.TestFailure{{
62
+ Code: "test_setup_failure",
63
+ Summary: "test command failed",
64
+ Details: lines,
65
+ }}
66
+ }
67
+ }
68
+
69
+ func splitNonEmptyLines(text string) []string {
70
+ parts := strings.Split(strings.TrimSpace(text), "\n")
71
+ result := make([]string, 0, len(parts))
72
+ for _, part := range parts {
73
+ trimmed := strings.TrimSpace(part)
74
+ if trimmed == "" {
75
+ continue
76
+ }
77
+ result = append(result, trimmed)
78
+ }
79
+ return result
80
+ }
@@ -0,0 +1,36 @@
1
+ package testingx
2
+
3
+ import "testing"
4
+
5
+ func TestClassifierDetectsAssertionFailure(t *testing.T) {
6
+ classifier := NewClassifier()
7
+ failures := classifier.Classify("--- FAIL: TestAuth\nexpected 200 got 500", "")
8
+ if len(failures) != 1 {
9
+ t.Fatalf("expected one failure classification")
10
+ }
11
+ if failures[0].Code != "test_assertion_failure" {
12
+ t.Fatalf("unexpected failure code: %s", failures[0].Code)
13
+ }
14
+ }
15
+
16
+ func TestClassifierDetectsTimeout(t *testing.T) {
17
+ classifier := NewClassifier()
18
+ failures := classifier.Classify("", "command timed out after 30s")
19
+ if len(failures) != 1 {
20
+ t.Fatalf("expected one failure classification")
21
+ }
22
+ if failures[0].Code != "test_timeout" {
23
+ t.Fatalf("unexpected failure code: %s", failures[0].Code)
24
+ }
25
+ }
26
+
27
+ func TestClassifierDetectsMissingTests(t *testing.T) {
28
+ classifier := NewClassifier()
29
+ failures := classifier.Classify("? package/foo [no test files]", "")
30
+ if len(failures) != 1 {
31
+ t.Fatalf("expected one failure classification")
32
+ }
33
+ if failures[0].Code != "missing_required_tests" {
34
+ t.Fatalf("unexpected failure code: %s", failures[0].Code)
35
+ }
36
+ }
@@ -0,0 +1,160 @@
1
+ package tools
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/furkanbeydemir/orch/internal/models"
15
+ )
16
+
17
+ const (
18
+ defaultCommandTimeout = 60 * time.Second
19
+ defaultTestTimeout = 120 * time.Second
20
+ maxOutputBytes = 50 * 1024
21
+ )
22
+
23
+ type RunCommandTool struct {
24
+ repoRoot string
25
+ }
26
+
27
+ func NewRunCommandTool(repoRoot string) *RunCommandTool {
28
+ return &RunCommandTool{repoRoot: repoRoot}
29
+ }
30
+
31
+ func (t *RunCommandTool) Name() string { return "run_command" }
32
+
33
+ func (t *RunCommandTool) Description() string { return "Runs a system command" }
34
+
35
+ func (t *RunCommandTool) Execute(params map[string]string) (*models.ToolResult, error) {
36
+ command, ok := params["command"]
37
+ if !ok {
38
+ return Failure("run_command", ErrCodeInvalidParams, "run_command: 'command' parameter is required", ""), nil
39
+ }
40
+
41
+ if risky, reason := classifyCommandRisk(command); risky && strings.TrimSpace(params["approved"]) != "true" {
42
+ return Failure("run_command", ErrCodePolicyBlocked, fmt.Sprintf("command blocked by safety policy: %s", reason), ""), nil
43
+ }
44
+
45
+ timeout := parseTimeout(params, defaultCommandTimeout)
46
+ return runCommand("run_command", t.repoRoot, command, timeout)
47
+ }
48
+
49
+ type RunTestsTool struct {
50
+ repoRoot string
51
+ }
52
+
53
+ func NewRunTestsTool(repoRoot string) *RunTestsTool {
54
+ return &RunTestsTool{repoRoot: repoRoot}
55
+ }
56
+
57
+ func (t *RunTestsTool) Name() string { return "run_tests" }
58
+
59
+ func (t *RunTestsTool) Description() string { return "Runs project tests" }
60
+
61
+ // Params: "command" optional test command (default: "go test ./...").
62
+ func (t *RunTestsTool) Execute(params map[string]string) (*models.ToolResult, error) {
63
+ command := params["command"]
64
+ if command == "" {
65
+ command = "go test ./..."
66
+ }
67
+
68
+ timeout := parseTimeout(params, defaultTestTimeout)
69
+ return runCommand("run_tests", t.repoRoot, command, timeout)
70
+ }
71
+
72
+ func runCommand(toolName, repoRoot, command string, timeout time.Duration) (*models.ToolResult, error) {
73
+
74
+ parts := strings.Fields(command)
75
+ if len(parts) == 0 {
76
+ return Failure(toolName, ErrCodeInvalidParams, "empty command", ""), nil
77
+ }
78
+
79
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
80
+ defer cancel()
81
+
82
+ cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
83
+ cmd.Dir = repoRoot
84
+
85
+ output, err := cmd.CombinedOutput()
86
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
87
+ return Failure(toolName, ErrCodeTimeout, fmt.Sprintf("command timed out after %s", timeout), truncateOutput(string(output))), nil
88
+ }
89
+
90
+ normalizedOutput, truncatedPath, truncated := normalizeOutput(repoRoot, toolName, output)
91
+ if err != nil {
92
+ result := Failure(toolName, ErrCodeExecution, err.Error(), normalizedOutput)
93
+ if truncated {
94
+ result.ErrorCode = ErrCodeOutputTrunc
95
+ result.Metadata = map[string]string{"output_file": truncatedPath}
96
+ }
97
+ return result, nil
98
+ }
99
+
100
+ result := Success(toolName, normalizedOutput)
101
+ if truncated {
102
+ result.Metadata = map[string]string{"output_file": truncatedPath}
103
+ }
104
+ return result, nil
105
+ }
106
+
107
+ func parseTimeout(params map[string]string, fallback time.Duration) time.Duration {
108
+ raw := strings.TrimSpace(params["timeout_seconds"])
109
+ if raw == "" {
110
+ return fallback
111
+ }
112
+ seconds, err := strconv.Atoi(raw)
113
+ if err != nil || seconds <= 0 {
114
+ return fallback
115
+ }
116
+ return time.Duration(seconds) * time.Second
117
+ }
118
+
119
+ func classifyCommandRisk(command string) (bool, string) {
120
+ lower := strings.ToLower(strings.TrimSpace(command))
121
+ riskyPatterns := []string{
122
+ "rm -rf",
123
+ "mkfs",
124
+ "dd if=",
125
+ "shutdown",
126
+ "reboot",
127
+ ":(){",
128
+ }
129
+ for _, pattern := range riskyPatterns {
130
+ if strings.Contains(lower, pattern) {
131
+ return true, pattern
132
+ }
133
+ }
134
+ return false, ""
135
+ }
136
+
137
+ func normalizeOutput(repoRoot, toolName string, output []byte) (string, string, bool) {
138
+ text := string(output)
139
+ if len(output) <= maxOutputBytes {
140
+ return text, "", false
141
+ }
142
+
143
+ if err := os.MkdirAll(filepath.Join(repoRoot, ".orch", "runs"), 0o755); err != nil {
144
+ return truncateOutput(text), "", true
145
+ }
146
+
147
+ path := filepath.Join(repoRoot, ".orch", "runs", fmt.Sprintf("%s-output-%d.log", toolName, time.Now().UnixNano()))
148
+ if err := os.WriteFile(path, output, 0o644); err != nil {
149
+ return truncateOutput(text), "", true
150
+ }
151
+
152
+ return fmt.Sprintf("output truncated; full output saved to %s\n%s", path, truncateOutput(text)), path, true
153
+ }
154
+
155
+ func truncateOutput(text string) string {
156
+ if len(text) <= maxOutputBytes {
157
+ return text
158
+ }
159
+ return text[:maxOutputBytes]
160
+ }