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,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
|
+
}
|