common-qa-mcp 0.2.0 → 0.4.0

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 (110) hide show
  1. package/README.md +6 -0
  2. package/dist/adapters/factory.d.ts +17 -7
  3. package/dist/adapters/factory.d.ts.map +1 -1
  4. package/dist/adapters/factory.js +44 -7
  5. package/dist/adapters/factory.js.map +1 -1
  6. package/dist/adapters/http.d.ts +50 -0
  7. package/dist/adapters/http.d.ts.map +1 -0
  8. package/dist/adapters/http.js +108 -0
  9. package/dist/adapters/http.js.map +1 -0
  10. package/dist/adapters/remoteMcp/adapter.d.ts +16 -2
  11. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  12. package/dist/adapters/remoteMcp/adapter.js +34 -5
  13. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  14. package/dist/adapters/simplifyqa/adapter.d.ts +437 -0
  15. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
  16. package/dist/adapters/simplifyqa/adapter.js +3097 -0
  17. package/dist/adapters/simplifyqa/adapter.js.map +1 -0
  18. package/dist/adapters/simplifyqa/mapper.d.ts +95 -0
  19. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
  20. package/dist/adapters/simplifyqa/mapper.js +858 -0
  21. package/dist/adapters/simplifyqa/mapper.js.map +1 -0
  22. package/dist/adapters/types.d.ts +168 -5
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/adapters/walnut/adapter.d.ts +235 -0
  25. package/dist/adapters/walnut/adapter.d.ts.map +1 -0
  26. package/dist/adapters/walnut/adapter.js +1114 -0
  27. package/dist/adapters/walnut/adapter.js.map +1 -0
  28. package/dist/adapters/walnut/mapper.d.ts +30 -9
  29. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  30. package/dist/adapters/walnut/mapper.js +149 -24
  31. package/dist/adapters/walnut/mapper.js.map +1 -1
  32. package/dist/config.d.ts +38 -14
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +73 -19
  35. package/dist/config.js.map +1 -1
  36. package/dist/connection.d.ts +3 -2
  37. package/dist/connection.d.ts.map +1 -1
  38. package/dist/connection.js +44 -15
  39. package/dist/connection.js.map +1 -1
  40. package/dist/domain/types.d.ts +680 -5
  41. package/dist/domain/types.d.ts.map +1 -1
  42. package/dist/drafts/store.d.ts +4 -1
  43. package/dist/drafts/store.d.ts.map +1 -1
  44. package/dist/drafts/store.js +7 -2
  45. package/dist/drafts/store.js.map +1 -1
  46. package/dist/initAgents.d.ts.map +1 -1
  47. package/dist/initAgents.js +48 -18
  48. package/dist/initAgents.js.map +1 -1
  49. package/dist/requestConnection.d.ts +5 -3
  50. package/dist/requestConnection.d.ts.map +1 -1
  51. package/dist/requestConnection.js +11 -5
  52. package/dist/requestConnection.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +47 -1
  55. package/dist/server.js.map +1 -1
  56. package/dist/tools/backlog.tools.d.ts +13 -0
  57. package/dist/tools/backlog.tools.d.ts.map +1 -0
  58. package/dist/tools/backlog.tools.js +399 -0
  59. package/dist/tools/backlog.tools.js.map +1 -0
  60. package/dist/tools/connection.tools.js +5 -5
  61. package/dist/tools/connection.tools.js.map +1 -1
  62. package/dist/tools/defects.tools.d.ts +7 -0
  63. package/dist/tools/defects.tools.d.ts.map +1 -0
  64. package/dist/tools/defects.tools.js +82 -0
  65. package/dist/tools/defects.tools.js.map +1 -0
  66. package/dist/tools/discovery.tools.d.ts.map +1 -1
  67. package/dist/tools/discovery.tools.js +951 -43
  68. package/dist/tools/discovery.tools.js.map +1 -1
  69. package/dist/tools/formulas.tools.d.ts +7 -0
  70. package/dist/tools/formulas.tools.d.ts.map +1 -0
  71. package/dist/tools/formulas.tools.js +56 -0
  72. package/dist/tools/formulas.tools.js.map +1 -0
  73. package/dist/tools/heal.tools.d.ts +17 -0
  74. package/dist/tools/heal.tools.d.ts.map +1 -0
  75. package/dist/tools/heal.tools.js +76 -0
  76. package/dist/tools/heal.tools.js.map +1 -0
  77. package/dist/tools/markdown.d.ts +3 -3
  78. package/dist/tools/markdown.d.ts.map +1 -1
  79. package/dist/tools/markdown.js +89 -18
  80. package/dist/tools/markdown.js.map +1 -1
  81. package/dist/tools/objects.tools.d.ts +7 -0
  82. package/dist/tools/objects.tools.d.ts.map +1 -0
  83. package/dist/tools/objects.tools.js +201 -0
  84. package/dist/tools/objects.tools.js.map +1 -0
  85. package/dist/tools/recording.tools.d.ts.map +1 -1
  86. package/dist/tools/recording.tools.js +92 -12
  87. package/dist/tools/recording.tools.js.map +1 -1
  88. package/dist/tools/run.tools.d.ts.map +1 -1
  89. package/dist/tools/run.tools.js +464 -39
  90. package/dist/tools/run.tools.js.map +1 -1
  91. package/dist/tools/schemas.d.ts +21 -4
  92. package/dist/tools/schemas.d.ts.map +1 -1
  93. package/dist/tools/schemas.js +14 -1
  94. package/dist/tools/schemas.js.map +1 -1
  95. package/dist/tools/storySync.tools.d.ts +66 -0
  96. package/dist/tools/storySync.tools.d.ts.map +1 -0
  97. package/dist/tools/storySync.tools.js +213 -0
  98. package/dist/tools/storySync.tools.js.map +1 -0
  99. package/dist/tools/tasks.tools.d.ts +7 -0
  100. package/dist/tools/tasks.tools.d.ts.map +1 -0
  101. package/dist/tools/tasks.tools.js +75 -0
  102. package/dist/tools/tasks.tools.js.map +1 -0
  103. package/dist/tools/testcase.tools.d.ts.map +1 -1
  104. package/dist/tools/testcase.tools.js +162 -101
  105. package/dist/tools/testcase.tools.js.map +1 -1
  106. package/dist/tools/testdata.tools.d.ts +7 -0
  107. package/dist/tools/testdata.tools.d.ts.map +1 -0
  108. package/dist/tools/testdata.tools.js +249 -0
  109. package/dist/tools/testdata.tools.js.map +1 -0
  110. package/package.json +3 -3
@@ -0,0 +1,1114 @@
1
+ import { ApiError, makeClient } from '../http.js';
2
+ import { WALNUT_ACTIONS, actionDefsFrom } from './actionTypes.js';
3
+ import { deriveTestData, fromWalnutObject, fromWalnutStory, fromWalnutStorySummary, fromWalnutSuite, fromWalnutSuiteSummary, fromWalnutTc, fromWalnutTcSummary, toWalnutStep, } from './mapper.js';
4
+ import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
5
+ /** Datarow `values` come back as a `[{name,value}]` array (prepareRowValuesForDb) or, for legacy
6
+ * rows, a flat object. Flatten either to `{ paramName: value }` so new values can be merged in. */
7
+ function flattenRowValues(raw) {
8
+ const out = {};
9
+ if (Array.isArray(raw)) {
10
+ for (const item of raw) {
11
+ const name = item?.name;
12
+ if (typeof name === 'string' && name)
13
+ out[name] = String(item.value ?? '');
14
+ }
15
+ }
16
+ else if (raw && typeof raw === 'object') {
17
+ for (const [k, v] of Object.entries(raw))
18
+ out[k] = v == null ? '' : String(v);
19
+ }
20
+ return out;
21
+ }
22
+ /** Neutral step technology -> Object Repository `object_type` (schemas.ts enum, UPPERCASE). Only
23
+ * element-bearing technologies get an object; api/database/control_flow/function never do. */
24
+ const OBJECT_TYPE_FOR_STEP = {
25
+ web: 'WEB',
26
+ android: 'ANDROID',
27
+ mainframe: 'MAINFRAME',
28
+ };
29
+ /** The user id (`id` claim) from a Walnut JWT's payload, for the `x-user-id` header the execution
30
+ * routes demand. Pure base64url decode — no signature check (the backend re-verifies the token). */
31
+ function userIdFromJwt(token) {
32
+ const seg = token.split('.')[1];
33
+ if (!seg)
34
+ return undefined;
35
+ try {
36
+ let b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
37
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
38
+ const claims = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
39
+ return typeof claims.id === 'string' ? claims.id : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /** Walnut execution `status` (schemas.ts:3315) → neutral RunStatus.state. `blocked` = couldn't run
46
+ * (setup/dependency) → surfaced as 'error'; `terminated`/`cancelled` = user/system stop → 'stopped'. */
47
+ function mapRunState(status) {
48
+ switch (status) {
49
+ case 'not_started': return 'queued';
50
+ case 'in_progress': return 'running';
51
+ case 'passed': return 'passed';
52
+ case 'failed': return 'failed';
53
+ case 'terminated':
54
+ case 'cancelled': return 'stopped';
55
+ case 'blocked': return 'error';
56
+ case 'skipped': return 'stopped';
57
+ default: return 'queued';
58
+ }
59
+ }
60
+ /** Reserve/trigger errors come back as coded JSON (no_cloud_runner_available, cloud_runner_offline,
61
+ * cloud_runner_not_eligible, enqueue_failed, …). Unwrap ApiError.body to the human message so the
62
+ * client sees "…is offline. Choose an online runner" instead of "HTTP 409". */
63
+ function unwrapApiError(e) {
64
+ if (e instanceof ApiError) {
65
+ try {
66
+ const j = JSON.parse(e.body);
67
+ const msg = j.message || j.error || j.code;
68
+ if (msg)
69
+ return new Error(msg);
70
+ }
71
+ catch {
72
+ // body wasn't JSON — fall through to the snippet
73
+ }
74
+ return new Error(e.body || `HTTP ${e.status}`);
75
+ }
76
+ return e instanceof Error ? e : new Error(String(e));
77
+ }
78
+ /**
79
+ * The backend's requireProjectAccess org-scopes the project lookup: if the CONNECTED project isn't in
80
+ * the TOKEN's organization, EVERY org-scoped call (tc_list / tc_get / local_agents / runs / …) fails
81
+ * with a bare `404 {"error":"Project not found"}` — which reads like a missing route, not a wrong
82
+ * project. (Hit live: a persisted last-project from org A while the token is org B.) Translate ONLY
83
+ * that case into an actionable hint; every other error (a genuine 404, 409, 503, …) is rethrown
84
+ * UNCHANGED, so status-based handling and real "not found" messages are untouched.
85
+ */
86
+ const PROJECT_SCOPE_HINT = "The connected project isn't accessible with this token — it's almost certainly not in your token's " +
87
+ 'organization (the project may be pinned from a different org). Run list_projects to see the projects ' +
88
+ 'this token can access, then qa_switch_project to switch to one.';
89
+ function isProjectNotFound(e) {
90
+ return e instanceof ApiError && e.status === 404 && /project not found/i.test(e.body || '');
91
+ }
92
+ /** Wrap a QaHttp so a project-scope 404 surfaces PROJECT_SCOPE_HINT instead of a bare "HTTP 404 …".
93
+ * Applied once to WalnutAdapter.http, so it covers every direct org-scoped call in one place. */
94
+ function withProjectScopeHint(http) {
95
+ const hint = (e) => {
96
+ if (isProjectNotFound(e))
97
+ throw new Error(PROJECT_SCOPE_HINT);
98
+ throw e;
99
+ };
100
+ return {
101
+ async getJson(path) { try {
102
+ return await http.getJson(path);
103
+ }
104
+ catch (e) {
105
+ return hint(e);
106
+ } },
107
+ async postJson(path, body) { try {
108
+ return await http.postJson(path, body);
109
+ }
110
+ catch (e) {
111
+ return hint(e);
112
+ } },
113
+ async putJson(path, body) { try {
114
+ return await http.putJson(path, body);
115
+ }
116
+ catch (e) {
117
+ return hint(e);
118
+ } },
119
+ async patchJson(path, body) { try {
120
+ return await http.patchJson(path, body);
121
+ }
122
+ catch (e) {
123
+ return hint(e);
124
+ } },
125
+ async del(path) { try {
126
+ await http.del(path);
127
+ }
128
+ catch (e) {
129
+ hint(e);
130
+ } },
131
+ async postMultipart(path, partName, json) { try {
132
+ return await http.postMultipart(path, partName, json);
133
+ }
134
+ catch (e) {
135
+ return hint(e);
136
+ } },
137
+ async putMultipart(path, partName, json) { try {
138
+ return await http.putMultipart(path, partName, json);
139
+ }
140
+ catch (e) {
141
+ return hint(e);
142
+ } },
143
+ };
144
+ }
145
+ /** Suite-run header status ('passed'|'failed'|'in_progress'|'cancelled') → neutral RunState. */
146
+ function mapSuiteRunState(status) {
147
+ switch (String(status ?? '').toLowerCase()) {
148
+ case 'passed': return 'passed';
149
+ case 'failed': return 'failed';
150
+ case 'in_progress': return 'running';
151
+ case 'cancelled':
152
+ case 'canceled': return 'stopped';
153
+ default: return 'queued';
154
+ }
155
+ }
156
+ const PRODUCT_URL_SEGMENT = {
157
+ testcase: 'test-cases',
158
+ suite: 'test-suites',
159
+ // The execution REPORT page in walnut-frontend is `/reports/:id` (App.tsx) — NOT `/executions/:id`
160
+ // (that route 404s). `:id` is the execution Mongo _id, which is what getRun uses as runId.
161
+ run: 'reports',
162
+ // ponytail: walnut has no reusable-function library — segment unused. Kept to satisfy the union.
163
+ function: 'functions',
164
+ };
165
+ /**
166
+ * WalnutAdapter — DIRECT-to-backend REST driver for Walnut (design note §3/§4). Every data op
167
+ * (discover / author / save / run-dispatch / triage) hits the Walnut backend REST directly via a
168
+ * `QaHttp` bound to `cfg.baseUrl` (the BACKEND base, `QA_BASE_URL`) — so a cloud editor with no local
169
+ * agent can drive the whole lifecycle. The ONLY agent-bound surface is recording + save-with-editor,
170
+ * which hops to the LOCAL AGENT's `/mcp` (`cfg.url`, `QA_MCP_URL`) via a lazily-built RemoteMcpAdapter
171
+ * (see `callRaw`/`listAgentTools`); when `cfg.url` is unset, recording is gracefully unavailable.
172
+ *
173
+ * This class deliberately does not `implements ProviderAdapter` — it ports only the direct-REST subset
174
+ * (journal, objects, environments, suite create/delete are out of scope here); the factory casts it,
175
+ * and no registered tool calls the unported methods.
176
+ */
177
+ export class WalnutAdapter {
178
+ constructor(cfg, transport) {
179
+ this.cfg = cfg;
180
+ /** Lazily-built hop to the LOCAL AGENT's /mcp (cfg.url) for the agent-bound recording surface. Only
181
+ * ever built when cfg.url is set; direct REST never uses it. */
182
+ this.agentHop = null;
183
+ // REST base is the BACKEND (cfg.baseUrl / QA_BASE_URL), NOT cfg.url (the agent /mcp endpoint). The
184
+ // factory only builds this adapter when baseUrl is set, so it is present at runtime.
185
+ this.http = withProjectScopeHint(makeClient({ baseUrl: cfg.baseUrl ?? '', headers: () => this.headers(), ...transport }));
186
+ }
187
+ // Auth headers: jwt mode sends the bearer token PLUS x-project-id (the token alone carries no
188
+ // project context) and x-user-id (the execution routes read the acting user from THIS header — see
189
+ // below); key mode sends only the bearer token, because an MCP API key already carries project
190
+ // context server-side. Sending x-project-id in key mode, or omitting it in jwt mode, breaks real
191
+ // requests against walnut-backend.
192
+ //
193
+ // x-user-id (jwt mode): the execution routes (POST /executions/reserve, /:id/stop, …) read the
194
+ // acting user from THIS header, not just from the token — omitting it 400s with "Project ID and
195
+ // User ID are required". It is derived from the token's own `id` claim, so it always matches the
196
+ // authenticated user; harmless on the routes that ignore it.
197
+ //
198
+ // NOTE (common-mcp adaptation): projectId is OPTIONAL here (a session can be connected before a
199
+ // project is chosen), so x-project-id is spread in only when present — mirroring RemoteMcpAdapter.
200
+ headers() {
201
+ if (this.cfg.walnutAuth !== 'jwt') {
202
+ return { Authorization: `Bearer ${this.cfg.token}`, 'Content-Type': 'application/json' };
203
+ }
204
+ const uid = userIdFromJwt(this.cfg.token);
205
+ return {
206
+ Authorization: `Bearer ${this.cfg.token}`,
207
+ ...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
208
+ ...(uid ? { 'x-user-id': uid } : {}),
209
+ 'Content-Type': 'application/json',
210
+ };
211
+ }
212
+ /** Validates baseUrl+token+project (`GET /api/projects/:id`). */
213
+ async info() {
214
+ const res = await this.http.getJson(`/api/projects/${this.cfg.projectId ?? ''}`);
215
+ const { project } = res;
216
+ const stats = project.statistics;
217
+ return {
218
+ provider: 'walnut',
219
+ projectId: project._id != null ? String(project._id) : (this.cfg.projectId ?? ''),
220
+ projectName: project.name,
221
+ counts: stats
222
+ ? {
223
+ stories: stats.stories_count ?? 0,
224
+ members: stats.members_count ?? 0,
225
+ testCases: stats.test_cases_count ?? 0,
226
+ defects: stats.defects_count ?? 0,
227
+ }
228
+ : undefined,
229
+ };
230
+ }
231
+ /** Projects visible to the token (`GET /api/projects`). Tolerant of both an array body and a
232
+ * `{ projects: [...] }` wrapper. */
233
+ async listProjects() {
234
+ const res = await this.http.getJson('/api/projects');
235
+ const rows = Array.isArray(res) ? res : (res?.projects ?? []);
236
+ return rows.map((row) => {
237
+ const p = row;
238
+ const stats = (p['statistics'] ?? {});
239
+ const num = (v) => (typeof v === 'number' ? v : 0);
240
+ return {
241
+ id: String(p['id'] ?? p['_id'] ?? ''),
242
+ name: String(p['name'] ?? ''),
243
+ counts: {
244
+ stories: num(stats['stories_count']),
245
+ members: num(stats['members_count']),
246
+ testCases: num(stats['test_cases_count']),
247
+ defects: num(stats['defects_count']),
248
+ },
249
+ };
250
+ });
251
+ }
252
+ async listTestCases(q) {
253
+ const params = new URLSearchParams();
254
+ if (q.search)
255
+ params.set('search', q.search);
256
+ if (q.limit != null)
257
+ params.set('limit', String(q.limit));
258
+ if (q.offset != null)
259
+ params.set('offset', String(q.offset));
260
+ const qs = params.toString();
261
+ const res = await this.http.getJson(`/api/test-cases${qs ? `?${qs}` : ''}`);
262
+ return (res.testCases ?? []).map((row) => fromWalnutTcSummary(row));
263
+ }
264
+ /** Neutral, steps included. `id` accepts either a Mongo ObjectId or a `TC_xxx` business key. */
265
+ // ponytail: Walnut has no version model — versionId is accepted for interface parity and ignored.
266
+ async getTestCase(id, _versionId) {
267
+ // Passed through untouched -- never validated/transformed. The backend itself
268
+ // resolves by shape (walnut-backend test-cases.ts:5156: 24-hex-char -> _id,
269
+ // else -> test_case_id).
270
+ const doc = await this.http.getJson(`/api/test-cases/${id}`);
271
+ return fromWalnutTc(doc);
272
+ }
273
+ /** List the project's test suites (`GET /api/test-suites`). `skip_filters=true` skips the faceted
274
+ * filter-metadata queries we don't need. Excludes ad-hoc suites server-side. `id` is the Mongo
275
+ * `_id` (what getSuite resolves by). */
276
+ async listSuites() {
277
+ const res = await this.http.getJson('/api/test-suites?skip_filters=true');
278
+ return (res.suites ?? []).map((row) => fromWalnutSuiteSummary(row));
279
+ }
280
+ /** Read one suite with its ordered members (`GET /api/test-suites/:id`). `id` is the Mongo `_id`
281
+ * (the route does NOT accept the `SU_` business key). */
282
+ async getSuite(id) {
283
+ const doc = await this.http.getJson(`/api/test-suites/${id}`);
284
+ return fromWalnutSuite(doc);
285
+ }
286
+ /** List the project's USER STORIES (`GET /api/stories?type=story`), for the story-driven authoring
287
+ * flow. `search` is a case-insensitive regex over key/title/description/AC (backend SEC-07 escaped).
288
+ * `id` on each row is the Mongo `_id` (what a test case links to via story_id). */
289
+ async listStories(q) {
290
+ const params = new URLSearchParams({ type: 'story' });
291
+ if (q.search)
292
+ params.set('search', q.search);
293
+ if (q.status)
294
+ params.set('status', q.status);
295
+ if (q.limit != null)
296
+ params.set('limit', String(q.limit));
297
+ if (q.offset != null)
298
+ params.set('offset', String(q.offset));
299
+ const res = await this.http.getJson(`/api/stories?${params.toString()}`);
300
+ return (res.stories ?? []).map((row) => fromWalnutStorySummary(row));
301
+ }
302
+ /** Read one user story with its acceptance criteria. `GET /api/stories/:id` resolves by Mongo `_id`
303
+ * ONLY (no business-key route), so a `US_123`/Jira key is resolved first via the list `search`
304
+ * endpoint, matching key/walnut_key. */
305
+ async getStory(id) {
306
+ const mongoId = await this.resolveStoryMongoId(id);
307
+ const doc = await this.http.getJson(`/api/stories/${mongoId}`);
308
+ return fromWalnutStory((doc.story ?? doc));
309
+ }
310
+ /** Resolve a story reference to its Mongo `_id`. A 24-hex id passes straight through; a business key
311
+ * (US_123 / an external Jira key) is looked up via `GET /api/stories?search=` and matched on
312
+ * key/walnut_key (there is no GET-by-key route). Throws a clear error when nothing matches. */
313
+ async resolveStoryMongoId(ref) {
314
+ if (/^[0-9a-f]{24}$/i.test(ref))
315
+ return ref;
316
+ // No GET-by-key route; and the list `search` regex covers key/title/description/AC but NOT the minted
317
+ // walnut_key (US_001). Native stories have an EMPTY `key` (only walnut_key), so a keyword search on
318
+ // "US_002" returns nothing. Scan the story list and match the business key (key / walnut_key /
319
+ // raw_key) client-side instead.
320
+ const params = new URLSearchParams({ type: 'story', limit: '200' });
321
+ const res = await this.http.getJson(`/api/stories?${params.toString()}`);
322
+ const rows = res.stories ?? [];
323
+ const norm = (v) => String(v ?? '').trim().toLowerCase();
324
+ const target = norm(ref);
325
+ const hit = rows.find((r) => norm(r['key']) === target || norm(r['walnut_key']) === target || norm(r['raw_key']) === target);
326
+ const id = hit?.['_id'] ?? hit?.['id'];
327
+ if (!id)
328
+ throw new Error(`No user story found for "${ref}" — check the story key (e.g. US_001) or pass its id.`);
329
+ return String(id);
330
+ }
331
+ async searchObjects(q) {
332
+ const params = new URLSearchParams();
333
+ if (q.query)
334
+ params.set('search', q.query);
335
+ if (q.pageName)
336
+ params.set('pageName', q.pageName);
337
+ if (q.limit != null)
338
+ params.set('limit', String(q.limit));
339
+ if (q.offset != null)
340
+ params.set('offset', String(q.offset));
341
+ const qs = params.toString();
342
+ const res = await this.http.getJson(`/api/objects${qs ? `?${qs}` : ''}`);
343
+ return (res.objects ?? []).map((row) => fromWalnutObject(row));
344
+ }
345
+ /** Read one object with its full locator bag (`GET /api/objects/:id`). */
346
+ async getObject(id) {
347
+ const doc = await this.http.getJson(`/api/objects/${id}`);
348
+ return fromWalnutObject(doc);
349
+ }
350
+ async deleteObject(id) {
351
+ await this.http.del(`/api/objects/${id}`);
352
+ }
353
+ /** Delete a test case (`DELETE /api/test-cases/:id`; recoverable via the product recycle bin). Like
354
+ * the update PUT, the route resolves ONLY by Mongo _id, so a business key is resolved first. */
355
+ async deleteTestCase(id) {
356
+ const mongoId = await this.resolveTestcaseMongoId(id);
357
+ try {
358
+ await this.http.del(`/api/test-cases/${mongoId}`);
359
+ }
360
+ catch (e) {
361
+ throw unwrapApiError(e);
362
+ }
363
+ }
364
+ /**
365
+ * Add / remove suite members (`PATCH /api/test-suites/:id/test-cases`, one action per call). The
366
+ * neutral op names a `tcId`, but the two backend actions speak different keys:
367
+ * • add → `testCaseIds[]` of MONGO _ids (the route casts them to ObjectId), so each tcId is
368
+ * resolved from a business key when needed. The backend appends with fresh instanceIds
369
+ * and rejects mixing test-case categories (manual vs automated) in one suite (409).
370
+ * • remove → a single `instanceId`. The suite stores instanceIds, not tcIds, so we map each
371
+ * requested tcId (business key OR Mongo _id) to the instanceId(s) it occupies in the
372
+ * CURRENT membership and remove each. Removing a tcId drops every instance of it.
373
+ * Returns the resulting neutral Suite. (reorder ops aren't surfaced as a tool; ignored here.)
374
+ */
375
+ async updateSuiteMembers(id, ops) {
376
+ const removes = ops.filter((o) => o.op === 'remove');
377
+ const adds = ops.filter((o) => o.op === 'add');
378
+ if (removes.length) {
379
+ const doc = await this.http.getJson(`/api/test-suites/${id}`);
380
+ const wanted = new Set(removes.map((o) => String(o.tcId)));
381
+ const instanceIds = [];
382
+ for (const m of doc.testCases ?? []) {
383
+ const tc = m.testCaseId;
384
+ const businessKey = typeof tc === 'object' && tc ? String(tc.test_case_id ?? '') : '';
385
+ const mongoId = typeof tc === 'object' && tc ? String(tc._id ?? '') : String(tc ?? '');
386
+ const inst = String(m.instanceId ?? '');
387
+ if (inst && (wanted.has(businessKey) || wanted.has(mongoId)))
388
+ instanceIds.push(inst);
389
+ }
390
+ if (!instanceIds.length) {
391
+ throw new Error(`None of the requested test cases are members of suite ${id}.`);
392
+ }
393
+ for (const instanceId of instanceIds) {
394
+ try {
395
+ await this.http.patchJson(`/api/test-suites/${id}/test-cases`, { action: 'remove', instanceId });
396
+ }
397
+ catch (e) {
398
+ throw unwrapApiError(e);
399
+ }
400
+ }
401
+ }
402
+ if (adds.length) {
403
+ const testCaseIds = await Promise.all(adds.map((o) => this.resolveTestcaseMongoId(String(o.tcId))));
404
+ try {
405
+ await this.http.patchJson(`/api/test-suites/${id}/test-cases`, { action: 'add', testCaseIds });
406
+ }
407
+ catch (e) {
408
+ throw unwrapApiError(e);
409
+ }
410
+ }
411
+ return this.getSuite(id);
412
+ }
413
+ /**
414
+ * Persist a NEW test case (`POST /api/test-cases`). The body mirrors the product's create schema:
415
+ * the category field is `test_category` (NOT `category`); `status` defaults to 'draft' server-side
416
+ * so we omit it; steps go through `toWalnutStep`. project comes from the x-project-id header and
417
+ * the creator from the JWT, so neither is in the body.
418
+ *
419
+ * Two-step, exactly like the UI: create the test case (the backend auto-creates a dataset with a
420
+ * column per `${param}` token and an EMPTY datarow), then fill that datarow via the SAME
421
+ * /api/test-data endpoint the Test Data tab uses. This keeps web value-handling in the MCP (no
422
+ * backend change) — walnut stores web values as `${param}` + datarow, never inline on the step.
423
+ */
424
+ async createTestCase(tc) {
425
+ // Prefer the agent hop when a QA_MCP_URL is configured (priority 1). The agent's save is the FULL
426
+ // flow — it opens the live editor on the saved TC and (with the journal work) re-keys the recording
427
+ // seed onto the new TC — neither of which the direct-REST path below does. Recording is always
428
+ // agent-hosted, so a recorded draft's seed lives on the agent; saving through the SAME agent keeps
429
+ // it journaled and shows the user the saved TC in the editor. Fall back to direct REST only when
430
+ // there is NO agent hop (a cloud editor with QA_BASE_URL only).
431
+ const saveHop = this.hop();
432
+ if (saveHop)
433
+ return saveHop.createTestCase(tc);
434
+ await this.ensureRecordedObjects(tc.steps); // mint + link objects so recorded steps are executable
435
+ // Link to a user story when supplied (manual-for-story authoring). The create route auto-derives
436
+ // feature_id/epic_id from the story; story_id must be the Mongo _id, so resolve a US_/Jira key first.
437
+ const storyId = tc.storyRef ? await this.resolveStoryMongoId(tc.storyRef) : undefined;
438
+ const body = {
439
+ title: tc.title,
440
+ test_category: tc.category,
441
+ priority: tc.priority,
442
+ test_steps: tc.steps.map(toWalnutStep),
443
+ ...(storyId ? { story_id: storyId } : {}),
444
+ ...(tc.description ? { description: tc.description } : {}),
445
+ ...(tc.objective ? { objective: tc.objective } : {}),
446
+ ...(tc.preconditions ? { preconditions: tc.preconditions } : {}),
447
+ ...(tc.expectedOutcome ? { expected_outcome: tc.expectedOutcome } : {}),
448
+ ...(tc.tags && tc.tags.length ? { tags: tc.tags } : {}),
449
+ };
450
+ const res = await this.http.postJson('/api/test-cases', body);
451
+ const saved = this.savedRefFrom(res, true);
452
+ // Fill the auto-created datarow with the ${param} values (derived per step, explicit tc.data
453
+ // winning). A failure here is surfaced, not swallowed — the whole point is to stop values from
454
+ // silently vanishing; the test case itself already exists, so the message says so.
455
+ const testData = { ...deriveTestData(tc.steps), ...(tc.data ?? {}) };
456
+ if (saved.id && Object.keys(testData).length) {
457
+ try {
458
+ await this.writeDefaultTestData(saved.id, testData);
459
+ }
460
+ catch (e) {
461
+ const msg = e instanceof Error ? e.message : String(e);
462
+ throw new Error(`Test case ${saved.id} was created, but writing its default test data failed: ${msg}. ` +
463
+ 'Open it and fill the Test Data tab manually.');
464
+ }
465
+ }
466
+ return saved;
467
+ }
468
+ /**
469
+ * Populate the datarow(s) of the dataset Walnut auto-creates from a test case's `${param}` tokens
470
+ * — the same second step the UI's Test Data tab performs (create leaves the columns empty). Values
471
+ * are keyed by PARAM NAME (the `${...}` token), which is how walnut stores them
472
+ * (prepareRowValuesForDb). No dataset linked (the test case had no `${param}` tokens) → no-op.
473
+ * Writes every seeded row (the create route makes one per project environment), merging over
474
+ * whatever the row already holds so nothing existing is clobbered.
475
+ */
476
+ async writeDefaultTestData(tcId, values) {
477
+ const doc = await this.http.getJson(`/api/test-cases/${tcId}`);
478
+ const datasetId = doc.linked_datasets?.[0]?.dataset_id;
479
+ if (!datasetId)
480
+ return; // no ${param} tokens → nothing was auto-created to fill
481
+ const rowsRes = await this.http.getJson(`/api/test-data/datasets/${datasetId}/rows`);
482
+ for (const row of rowsRes.rows ?? []) {
483
+ if (!row._id)
484
+ continue;
485
+ const merged = { ...flattenRowValues(row.values), ...values };
486
+ await this.http.putJson(`/api/test-data/rows/${row._id}`, { values: merged });
487
+ }
488
+ }
489
+ /** Update an EXISTING test case, full-document (`PUT /api/test-cases/:id`). Sends the neutral
490
+ * `version` for optimistic concurrency when present. */
491
+ async updateTestCase(id, tc) {
492
+ // Prefer the agent hop when configured (priority 1) — same rationale as createTestCase: the agent
493
+ // save opens the editor and (with the journal work) keeps the seed in sync. Direct REST only when
494
+ // there is no agent hop.
495
+ const saveHop = this.hop();
496
+ if (saveHop)
497
+ return saveHop.updateTestCase(id, tc);
498
+ await this.ensureRecordedObjects(tc.steps); // same object-minting as create, for edited/recorded steps
499
+ // Preserve/attach the story link on update too (the manual→automated promote keeps it).
500
+ const storyId = tc.storyRef ? await this.resolveStoryMongoId(tc.storyRef) : undefined;
501
+ const body = {
502
+ title: tc.title,
503
+ test_category: tc.category,
504
+ priority: tc.priority,
505
+ test_steps: tc.steps.map(toWalnutStep),
506
+ ...(storyId ? { story_id: storyId } : {}),
507
+ // Full-document semantics: this PUT carries the COMPLETE step list, so removed steps must be
508
+ // deleted, not left lingering. The backend only reconciles deletions behind this opt-in flag
509
+ // (test-cases.ts PUT :6132) — without it, a step dropped from the payload survives (each kept
510
+ // step is matched by its _id, emitted below via toWalnutStep). Together = a clean replace.
511
+ reconcile_step_deletions: true,
512
+ ...(tc.status ? { status: tc.status } : {}),
513
+ ...(tc.description ? { description: tc.description } : {}),
514
+ ...(tc.version != null ? { version: tc.version } : {}),
515
+ };
516
+ // PUT /api/test-cases/:id resolves ONLY by Mongo _id (findOne({_id:id})) — a business key like
517
+ // TC_123 makes Mongo throw a CastError → generic 500. GET tolerates both; PUT does not. Resolve
518
+ // to the _id first (24-hex passes straight through).
519
+ const mongoId = await this.resolveTestcaseMongoId(id);
520
+ const res = await this.http.putJson(`/api/test-cases/${mongoId}`, body);
521
+ return this.savedRefFrom(res, false);
522
+ }
523
+ /** Neutral SavedRef from a create/update response, tolerant of the doc being bare or wrapped
524
+ * (`{ testCase }` / `{ data }`). Prefers the business id (`test_case_id`) for the deep link. */
525
+ savedRefFrom(res, created) {
526
+ const r = (res ?? {});
527
+ const doc = ((r['testCase'] ?? r['data'] ?? r) ?? {});
528
+ const businessId = doc['test_case_id'] != null ? String(doc['test_case_id']) : '';
529
+ const mongoId = doc['_id'] != null ? String(doc['_id']) : '';
530
+ const id = businessId || mongoId;
531
+ return {
532
+ id,
533
+ created,
534
+ ...(id ? { productUrl: this.productUrlFor({ kind: 'testcase', id }) } : {}),
535
+ };
536
+ }
537
+ /**
538
+ * The credential of the ONLINE local agent for this project — the SAME one the UI's Run modal
539
+ * dispatches to. Sourced from the live roster (`GET /api/agent-credentials/project-agents`). The
540
+ * roster reflects real Redis/heartbeat presence, so `is_online` always targets a live poller.
541
+ * Returns null when the acting user has no online agent, OR when the roster endpoint is unreachable
542
+ * (e.g. the `ai-agents` feature flag is off) — the caller then surfaces a clear "no local agent"
543
+ * error. (The agent's cached-credential fallback is agent-only and intentionally not ported here.)
544
+ */
545
+ async resolveLocalAgentCredential() {
546
+ try {
547
+ const res = await this.http.getJson('/api/agent-credentials/project-agents');
548
+ const agents = res.agents ?? [];
549
+ // A SHARED project lists EVERY member's agent. A local run must go to THE ACTING USER'S OWN
550
+ // agent — NOT the first online one in the roster, which is very likely a teammate's machine.
551
+ // Dispatching there sends the run to someone else's box (where it "passes in the UI but crashes
552
+ // via MCP", because the UI runs on the user's own agent) and is an isolation problem besides.
553
+ // Match the user from the JWT `id` claim, preferring their most-recently-seen online agent.
554
+ const uid = userIdFromJwt(this.cfg.token);
555
+ if (uid) {
556
+ const mine = agents
557
+ .filter((a) => a.is_online && a.credential_id
558
+ && (a.owner_user_id === uid || a.agent_user_id === uid))
559
+ .sort((a, b) => (b.last_seen_at ?? '').localeCompare(a.last_seen_at ?? ''));
560
+ return mine[0]?.credential_id ?? null; // null = the acting user has no online agent
561
+ }
562
+ // Raw API key (no user identity in the token): can't match an owner — fall back to first online.
563
+ return agents.find((a) => a.is_online && a.credential_id)?.credential_id ?? null;
564
+ }
565
+ catch {
566
+ return null; // roster unreachable → caller surfaces a clear "no local agent" error
567
+ }
568
+ }
569
+ /**
570
+ * Persist recorded elements into the Object Repository so a saved test case is EXECUTABLE.
571
+ *
572
+ * A recorded step carries `objectRef.locators` (real captured selectors) but no `objectRef.id` —
573
+ * it's a NEW object not yet in the repository. Walnut binds a step to its element via `object_id`
574
+ * (a repository row); it does NOT store locators inline on the step. So without this, the save
575
+ * drops every locator (object_id stays null) and the test case can't find its elements at run time.
576
+ *
577
+ * For each such step we POST /api/objects (description = the element name, attributes = the recorded
578
+ * locators verbatim — the executor reads them by name: `playwright` primary, `xpath_*` fallbacks)
579
+ * and stamp the returned `_id` onto `objectRef.id` so `toWalnutStep` then emits `object_id`. Runs
580
+ * BEFORE the test-case create, so a failure aborts cleanly with nothing half-saved (any objects
581
+ * already minted are just unlinked repository rows).
582
+ */
583
+ async ensureRecordedObjects(steps) {
584
+ for (const s of steps) {
585
+ const ref = s.objectRef;
586
+ if (!ref || ref.id || !ref.locators?.length)
587
+ continue; // already linked, or nothing captured
588
+ const objectType = OBJECT_TYPE_FOR_STEP[s.stepType];
589
+ if (!objectType)
590
+ continue; // api/db/control-flow/function steps have no UI object
591
+ const body = {
592
+ description: ref.name ?? s.description ?? 'Recorded element',
593
+ object_type: objectType,
594
+ attributes: ref.locators.map((l) => ({ name: l.name, value: l.value })),
595
+ };
596
+ let res;
597
+ try {
598
+ res = await this.http.postJson('/api/objects', body);
599
+ }
600
+ catch (e) {
601
+ throw new Error(`Failed to create the Object Repository entry for step ${s.n} ("${body.description}"): ` +
602
+ `${e instanceof Error ? e.message : String(e)}. The test case was not saved.`);
603
+ }
604
+ if (!res?._id)
605
+ throw new Error(`Object create for step ${s.n} returned no id; the test case was not saved.`);
606
+ ref.id = String(res._id); // link → toWalnutStep now emits object_id → executable
607
+ }
608
+ }
609
+ /** A neutral tcId may be a business key (`TC_123`) or a Mongo `_id`; the executions API wants the
610
+ * `_id`. 24-hex → pass through; otherwise resolve via the test-case read. */
611
+ async resolveTestcaseMongoId(tcId) {
612
+ if (/^[0-9a-f]{24}$/i.test(tcId))
613
+ return tcId;
614
+ const doc = await this.http.getJson(`/api/test-cases/${tcId}`);
615
+ if (!doc?._id)
616
+ throw new Error(`Could not resolve test case "${tcId}" to an id.`);
617
+ return String(doc._id);
618
+ }
619
+ /**
620
+ * Start a run of ONE test case (`POST /api/executions/reserve`; reserve-first is the only path for
621
+ * cloud, and the modern path for local). mode:'local' dispatches to THIS agent's own queue (its
622
+ * cached agent credential); mode:'cloud' dispatches to the chosen online cloud runner. The backend
623
+ * mints the execution + enqueues; per-step driving happens on the agent/runner. `environment` is
624
+ * used only when it is already a Mongo id — otherwise the backend applies the project default env.
625
+ */
626
+ async startRun(target, opts) {
627
+ if (target.suiteId)
628
+ return this.startSuiteRun(target.suiteId, opts);
629
+ if (!target.tcId)
630
+ throw new Error('startRun: a test case id (tcId) or a suite id (suiteId) is required.');
631
+ const isCloud = opts.mode === 'cloud';
632
+ const body = {
633
+ testcase_id: await this.resolveTestcaseMongoId(target.tcId),
634
+ execution_type: 'automated',
635
+ execution_target: isCloud ? 'cloud_runner' : 'local',
636
+ // Default to bundled 'chromium', NOT 'chrome': the 'chrome' channel launches the user's real
637
+ // installed Google Chrome, which exits within ~2s ("Browser disconnected UNEXPECTEDLY / external
638
+ // close") when Chrome is already open (profile/singleton conflict) or version-mismatched with the
639
+ // agent's Playwright. 'chromium' ships with the agent and runs an isolated profile — the same
640
+ // stable browser a UI run uses. Callers can still pass browser:'chrome'/'msedge'/'firefox'/'webkit'.
641
+ browser: opts.browser ?? 'chromium',
642
+ headless: opts.headless ?? isCloud, // cloud VMs run headless; local shows the browser by default
643
+ ...(opts.environment && /^[0-9a-f]{24}$/i.test(opts.environment) ? { environment_id: opts.environment } : {}),
644
+ };
645
+ if (isCloud) {
646
+ if (opts.cloudRunnerId)
647
+ body['cloud_runner_id'] = opts.cloudRunnerId; // else backend auto-picks
648
+ }
649
+ else {
650
+ // A caller-picked agent (opts.agentId, from listLocalAgents) dispatches to THAT agent; otherwise
651
+ // auto-pick the acting user's own online agent (the roster-backed resolveLocalAgentCredential).
652
+ const cred = opts.agentId || (await this.resolveLocalAgentCredential());
653
+ if (!cred) {
654
+ throw new Error('No local agent registered to YOUR account is online for this project. Open YOUR Walnut ' +
655
+ 'agent and make sure it shows connected/verified (its task poller must be running), or run ' +
656
+ 'on cloud. (A local run only ever dispatches to your own agent, never a teammate\'s.)');
657
+ }
658
+ body['agent_credential_id'] = cred;
659
+ }
660
+ let res;
661
+ try {
662
+ res = await this.http.postJson('/api/executions/reserve', body);
663
+ }
664
+ catch (e) {
665
+ throw unwrapApiError(e);
666
+ }
667
+ const runId = res.execution_mongo_id ?? res._id ?? res.execution_id;
668
+ if (!runId)
669
+ throw new Error('Run was triggered but the backend returned no execution id.');
670
+ return { runId: String(runId) };
671
+ }
672
+ /**
673
+ * Start a SUITE run (`POST /api/executions/reserve` in suite mode — `testcase_ids[]` present).
674
+ * Fetches the suite's ordered members to build the parallel `testcase_ids`/`instance_ids` the
675
+ * reserve expects, then dispatches: mode:'local' → THIS agent's own queue (its online credential,
676
+ * which the backend requires for a local suite); mode:'cloud' → the chosen/auto-picked online
677
+ * runner. Returns the suite-run id (`SR_xxx`) as runId — getRun/stopRun route it back to the suite
678
+ * endpoints by that prefix.
679
+ */
680
+ async startSuiteRun(suiteId, opts) {
681
+ const doc = await this.http.getJson(`/api/test-suites/${suiteId}`);
682
+ const members = (doc.testCases ?? [])
683
+ .filter((m) => m && m.testCaseId)
684
+ .slice()
685
+ .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
686
+ if (!members.length)
687
+ throw new Error(`Suite ${suiteId} has no runnable test cases.`);
688
+ const testcaseIds = members.map((m) => {
689
+ const tc = m.testCaseId;
690
+ return String(typeof tc === 'object' && tc ? (tc._id ?? '') : (tc ?? ''));
691
+ });
692
+ const instanceIds = members.map((m) => String(m.instanceId ?? ''));
693
+ const isCloud = opts.mode === 'cloud';
694
+ const body = {
695
+ testcase_ids: testcaseIds,
696
+ instance_ids: instanceIds,
697
+ // Local is always serial (the backend forces it); cloud honours the requested strategy.
698
+ strategy: isCloud ? (opts.strategy ?? 'serial') : 'serial',
699
+ execution_type: 'automated',
700
+ execution_target: isCloud ? 'cloud_runner' : 'local',
701
+ suite_id: suiteId,
702
+ browser: opts.browser ?? 'chromium',
703
+ headless: opts.headless ?? isCloud,
704
+ ...(opts.environment && /^[0-9a-f]{24}$/i.test(opts.environment) ? { environment_id: opts.environment } : {}),
705
+ ...(opts.terminateOnFailure !== undefined ? { terminate_on_failure: opts.terminateOnFailure } : {}),
706
+ ...(isCloud && opts.maxConcurrent ? { max_concurrent: opts.maxConcurrent } : {}),
707
+ };
708
+ if (isCloud) {
709
+ if (opts.cloudRunnerId)
710
+ body['cloud_runner_id'] = opts.cloudRunnerId; // else backend auto-picks
711
+ }
712
+ else {
713
+ // A caller-picked agent (opts.agentId, from listLocalAgents) dispatches to THAT agent; otherwise
714
+ // auto-pick the acting user's own online agent.
715
+ const cred = opts.agentId || (await this.resolveLocalAgentCredential());
716
+ if (!cred) {
717
+ throw new Error('No local agent registered to YOUR account is online for this project. Open YOUR Walnut ' +
718
+ 'agent and make sure it shows connected/verified, or run the suite on cloud.');
719
+ }
720
+ body['agent_credential_id'] = cred;
721
+ }
722
+ let res;
723
+ try {
724
+ res = await this.http.postJson('/api/executions/reserve', body);
725
+ }
726
+ catch (e) {
727
+ throw unwrapApiError(e);
728
+ }
729
+ if (!res.suite_execution_id) {
730
+ throw new Error('Suite run was triggered but the backend returned no suite execution id.');
731
+ }
732
+ return { runId: String(res.suite_execution_id) };
733
+ }
734
+ /**
735
+ * Create a SCHEDULE — a deferred / recurring run — instead of dispatching now (`POST /api/schedules`,
736
+ * the same route the Scheduler UI uses). Maps the neutral target + ScheduleOptions to the backend's
737
+ * createScheduleSchema: schedule_target ('test_case' when a tcId is given, else 'suite'); tc_id /
738
+ * suite_id (the backend resolves a tc_id business key OR _id, but a suite_id must be a Mongo _id);
739
+ * execution_target ('local' | 'cloud_runner' from opts.mode); assigned_agent_credential_id (the
740
+ * picked local agent for local, or the picked cloud runner for cloud — omit to let the backend
741
+ * auto-pick a VM); scheduled_for (REQUIRED ISO); schedule_type (default 'once'); end_date; timezone
742
+ * (default 'UTC'); environment_id (only when a 24-hex id, mirroring startRun). MANUAL test cases are
743
+ * rejected server-side (MANUAL_NOT_SCHEDULABLE_CODE); unwrapApiError surfaces that message verbatim.
744
+ */
745
+ async createSchedule(target, opts) {
746
+ if (!target.tcId && !target.suiteId) {
747
+ throw new Error('createSchedule: a test case id (tcId) or a suite id (suiteId) is required.');
748
+ }
749
+ if (!opts.scheduledFor) {
750
+ throw new Error('createSchedule: scheduledFor (an ISO datetime) is required for a schedule.');
751
+ }
752
+ const isCloud = opts.mode === 'cloud';
753
+ // A schedule fires detached from any live session, so a LOCAL schedule MUST target a specific
754
+ // agent — there is no "caller's own agent" at fire time. The backend only requires+honors the
755
+ // pin in execution_mode:'manual' (needsAssignedAgent), so we must send both.
756
+ if (!isCloud && !opts.agentId) {
757
+ throw new Error('createSchedule: a LOCAL schedule must target a specific agent (agentId — a credential_id ' +
758
+ 'from listLocalAgents). It runs detached from any live session, so there is no default agent.');
759
+ }
760
+ const body = {
761
+ schedule_target: target.tcId ? 'test_case' : 'suite',
762
+ ...(target.tcId ? { tc_id: target.tcId } : {}),
763
+ ...(target.suiteId ? { suite_id: target.suiteId } : {}),
764
+ schedule_type: opts.scheduleType ?? 'once',
765
+ execution_target: isCloud ? 'cloud_runner' : 'local',
766
+ // LOCAL: pin the picked agent. execution_mode:'manual' is what makes the backend REQUIRE and
767
+ // honor assigned_agent_credential_id (needsAssignedAgent); without it the pin is ignored and a
768
+ // detached schedule has no agent to run on. (A local agent credential that is actually a
769
+ // test_runner_vm cred is auto-promoted to cloud_runner by the backend — see schedules.ts.)
770
+ // CLOUD: the backend forces execution_mode='manual' itself and takes assigned_agent_credential_id
771
+ // as an OPTIONAL pin — the runner's test_runner_vm CREDENTIAL id (CloudRunner.credentialId, a
772
+ // DIFFERENT id-space from cloudRunnerId/compute_configs._id). Omit it to auto-pick a live VM at
773
+ // fire time. A pinned VM that is offline at create time is rejected by the backend.
774
+ ...(isCloud
775
+ ? (opts.cloudRunnerCredentialId ? { assigned_agent_credential_id: opts.cloudRunnerCredentialId } : {})
776
+ : { execution_mode: 'manual', assigned_agent_credential_id: opts.agentId }),
777
+ // Absent browser is intentional: the backend then falls back to each test case's own saved
778
+ // browser instead of forcing one (createScheduleSchema.browser has no default).
779
+ ...(opts.browser ? { browser: opts.browser } : {}),
780
+ scheduled_for: opts.scheduledFor,
781
+ ...(opts.endDate ? { end_date: opts.endDate } : {}),
782
+ timezone: opts.timezone ?? 'UTC',
783
+ ...(opts.environmentId && /^[0-9a-f]{24}$/i.test(opts.environmentId) ? { environment_id: opts.environmentId } : {}),
784
+ };
785
+ let res;
786
+ try {
787
+ res = await this.http.postJson('/api/schedules', body);
788
+ }
789
+ catch (e) {
790
+ throw unwrapApiError(e); // surfaces MANUAL_NOT_SCHEDULABLE + coded schedule errors as the human message
791
+ }
792
+ const doc = ((res?.data ?? res) ?? {});
793
+ const id = doc['_id'] != null ? String(doc['_id']) : '';
794
+ if (!id)
795
+ throw new Error('Schedule was created but the backend returned no schedule id.');
796
+ return {
797
+ id,
798
+ ...(typeof doc['status'] === 'string' ? { status: doc['status'] } : {}),
799
+ ...(doc['next_run_at'] != null ? { nextRunAt: String(doc['next_run_at']) } : {}),
800
+ };
801
+ }
802
+ /** Map one raw schedule row (`GET /api/schedules`) to a neutral ScheduleInfo — human target + cadence
803
+ * + status + pinned agent NAME (never the credential_id) + the update_version an edit must echo. */
804
+ toScheduleInfo(r) {
805
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
806
+ const suiteId = str(r['suite_id']);
807
+ const suiteName = str(r['suite_name']);
808
+ const tcKey = str(r['tc_identifier']) ?? str(r['tc_id']);
809
+ const tcName = str(r['tc_name']);
810
+ const target = suiteId
811
+ ? `Suite${suiteName ? `: ${suiteName}` : ` ${suiteId}`}`
812
+ : `${tcKey ?? '(test case)'}${tcName ? ` — ${tcName}` : ''}`;
813
+ const et = str(r['execution_target']);
814
+ const agent = str(r['assigned_agent_owner_name']);
815
+ const browser = str(r['browser']);
816
+ const tz = str(r['timezone']);
817
+ return {
818
+ id: String(r['_id'] ?? ''),
819
+ target,
820
+ scheduleType: str(r['schedule_type']) ?? 'once',
821
+ status: str(r['status']) ?? 'scheduled',
822
+ ...(typeof r['is_active'] === 'boolean' ? { active: r['is_active'] } : {}),
823
+ ...(et === 'local' || et === 'cloud_runner' ? { executionTarget: et } : {}),
824
+ ...(agent ? { agent } : {}),
825
+ ...(browser ? { browser } : {}),
826
+ ...(r['next_run_at'] != null ? { nextRunAt: String(r['next_run_at']) } : {}),
827
+ ...(r['scheduled_for'] != null ? { scheduledFor: String(r['scheduled_for']) } : {}),
828
+ ...(tz ? { timezone: tz } : {}),
829
+ ...(typeof r['update_version'] === 'number' ? { updateVersion: r['update_version'] } : {}),
830
+ };
831
+ }
832
+ /** List the project's schedules for management (`GET /api/schedules`). */
833
+ async listSchedules() {
834
+ let res;
835
+ try {
836
+ res = await this.http.getJson('/api/schedules');
837
+ }
838
+ catch (e) {
839
+ throw unwrapApiError(e);
840
+ }
841
+ const rows = (res.schedules ?? (Array.isArray(res.data) ? res.data : []));
842
+ return rows.map((r) => this.toScheduleInfo(r));
843
+ }
844
+ /** Fetch one raw schedule row (via the list) — needed for its update_version before an edit. */
845
+ async fetchScheduleRaw(scheduleId) {
846
+ let res;
847
+ try {
848
+ res = await this.http.getJson('/api/schedules');
849
+ }
850
+ catch (e) {
851
+ throw unwrapApiError(e);
852
+ }
853
+ const found = (res.schedules ?? []).find((r) => String(r['_id']) === scheduleId);
854
+ if (!found)
855
+ throw new Error(`Schedule ${scheduleId} was not found in this project.`);
856
+ return found;
857
+ }
858
+ /** Edit a schedule (`PUT /api/schedules/:id`). Reads the current update_version itself (optimistic
859
+ * concurrency) so the caller passes only what changes. Re-pins via mode+agentId (local → also sets
860
+ * execution_mode:'manual') or mode+cloudRunnerCredentialId (cloud), mirroring createSchedule. */
861
+ async updateSchedule(scheduleId, changes) {
862
+ const current = await this.fetchScheduleRaw(scheduleId);
863
+ const uv = current['update_version'];
864
+ const body = {
865
+ update_version: typeof uv === 'number' ? uv : 0,
866
+ ...(changes.scheduledFor ? { scheduled_for: changes.scheduledFor } : {}),
867
+ ...(changes.scheduleType ? { schedule_type: changes.scheduleType } : {}),
868
+ ...(changes.endDate ? { end_date: changes.endDate } : {}),
869
+ ...(changes.timezone ? { timezone: changes.timezone } : {}),
870
+ ...(changes.browser ? { browser: changes.browser } : {}),
871
+ ...(changes.environmentId && /^[0-9a-f]{24}$/i.test(changes.environmentId) ? { environment_id: changes.environmentId } : {}),
872
+ };
873
+ if (changes.mode === 'local' && changes.agentId) {
874
+ body['execution_target'] = 'local';
875
+ body['execution_mode'] = 'manual';
876
+ body['assigned_agent_credential_id'] = changes.agentId;
877
+ }
878
+ else if (changes.mode === 'cloud' && changes.cloudRunnerCredentialId) {
879
+ body['execution_target'] = 'cloud_runner';
880
+ body['assigned_agent_credential_id'] = changes.cloudRunnerCredentialId;
881
+ }
882
+ let res;
883
+ try {
884
+ res = await this.http.putJson(`/api/schedules/${scheduleId}`, body);
885
+ }
886
+ catch (e) {
887
+ throw unwrapApiError(e);
888
+ }
889
+ const doc = ((res?.data ?? res) ?? {});
890
+ return {
891
+ id: doc['_id'] != null ? String(doc['_id']) : scheduleId,
892
+ ...(typeof doc['status'] === 'string' ? { status: doc['status'] } : {}),
893
+ ...(doc['next_run_at'] != null ? { nextRunAt: String(doc['next_run_at']) } : {}),
894
+ };
895
+ }
896
+ /** Pause / resume / cancel a schedule. pause→`POST /:id/pause`, resume→`POST /:id/resume`,
897
+ * cancel→`PATCH /:id/cancel` (all bodyless). */
898
+ async controlSchedule(scheduleId, action) {
899
+ try {
900
+ const res = action === 'cancel'
901
+ ? await this.http.patchJson(`/api/schedules/${scheduleId}/cancel`, {})
902
+ : await this.http.postJson(`/api/schedules/${scheduleId}/${action}`, {});
903
+ const doc = ((res?.data ?? res) ?? {});
904
+ return {
905
+ id: doc['_id'] != null ? String(doc['_id']) : scheduleId,
906
+ ...(typeof doc['status'] === 'string' ? { status: doc['status'] } : {}),
907
+ ...(doc['next_run_at'] != null ? { nextRunAt: String(doc['next_run_at']) } : {}),
908
+ };
909
+ }
910
+ catch (e) {
911
+ throw unwrapApiError(e);
912
+ }
913
+ }
914
+ /** Delete a schedule permanently (`DELETE /api/schedules/:id`; the backend also tears down any live run). */
915
+ async deleteSchedule(scheduleId) {
916
+ try {
917
+ await this.http.del(`/api/schedules/${scheduleId}`);
918
+ }
919
+ catch (e) {
920
+ throw unwrapApiError(e);
921
+ }
922
+ }
923
+ /** Neutral status of a run (`GET /api/executions/:id`, id = Mongo `_id`). Progress is derived from
924
+ * the per-status step counters; a UI deep link stands in for a report URL. */
925
+ async getRun(runId) {
926
+ if (/^SR_/i.test(runId))
927
+ return this.getSuiteRun(runId);
928
+ const doc = await this.http.getJson(`/api/executions/${runId}`);
929
+ const num = (v) => (typeof v === 'number' ? v : 0);
930
+ const done = num(doc['passed_steps']) + num(doc['failed_steps']) + num(doc['skipped_steps']) + num(doc['blocked_steps']);
931
+ const total = num(doc['total_steps']);
932
+ return {
933
+ runId,
934
+ state: mapRunState(doc['status']),
935
+ ...(total > 0 ? { progress: { done, total } } : {}),
936
+ reportUrl: this.productUrlFor({ kind: 'run', id: runId }),
937
+ };
938
+ }
939
+ /** Neutral status of a SUITE run (`GET /api/executions/suite/:suiteRunId/summary`). `progress`
940
+ * counts TEST CASES (not steps); `children` is the per-testcase rollup. A 404 right after reserve
941
+ * (child execution docs not materialized yet) reads as 'queued', not an error. No reportUrl: the
942
+ * `/reports/:id` deep link is keyed by a single execution's Mongo id, not a suite-run `SR_` id. */
943
+ async getSuiteRun(suiteRunId) {
944
+ let doc;
945
+ try {
946
+ doc = await this.http.getJson(`/api/executions/suite/${suiteRunId}/summary`);
947
+ }
948
+ catch (e) {
949
+ if (e instanceof ApiError && e.status === 404)
950
+ return { runId: suiteRunId, state: 'queued' };
951
+ throw unwrapApiError(e);
952
+ }
953
+ const num = (v) => (typeof v === 'number' ? v : 0);
954
+ const t = doc.totals ?? {};
955
+ const total = num(t['total']);
956
+ const done = num(t['passed']) + num(t['failed']) + num(t['skipped']) + num(t['blocked']) + num(t['terminated']);
957
+ const children = (doc.testcases ?? []).map((row) => ({
958
+ id: String(row['testcase_identifier'] ?? row['testcase_id'] ?? ''),
959
+ ...(typeof row['testcase_title'] === 'string' ? { title: row['testcase_title'] } : {}),
960
+ state: mapRunState(row['status']),
961
+ }));
962
+ return {
963
+ runId: suiteRunId,
964
+ state: mapSuiteRunState(doc.suite?.status),
965
+ ...(total > 0 ? { progress: { done, total } } : {}),
966
+ ...(children.length ? { children } : {}),
967
+ };
968
+ }
969
+ /** Signal a running execution to stop. Suite runs (`SR_...`) stop via the suite endpoint
970
+ * (`POST /api/executions/suite/:id/stop`); single executions via `POST /api/executions/:id/stop`.
971
+ * No body either way. */
972
+ async stopRun(runId) {
973
+ const path = /^SR_/i.test(runId)
974
+ ? `/api/executions/suite/${runId}/stop`
975
+ : `/api/executions/${runId}/stop`;
976
+ try {
977
+ await this.http.postJson(path, {});
978
+ }
979
+ catch (e) {
980
+ throw unwrapApiError(e);
981
+ }
982
+ }
983
+ /** Cloud runner VMs registered to the project + live online status
984
+ * (`GET /api/compute-configs/runners?live=1`). `online` already folds status online|busy. */
985
+ async listCloudRunners() {
986
+ const res = await this.http.getJson('/api/compute-configs/runners?live=1');
987
+ return (res.runners ?? []).map((r) => ({
988
+ id: String(r['id'] ?? ''),
989
+ name: String(r['name'] ?? ''),
990
+ online: r['online'] === true,
991
+ ...(typeof r['status'] === 'string' ? { status: r['status'] } : {}),
992
+ ...(typeof r['host'] === 'string' ? { os: r['host'] } : {}),
993
+ // The linked test_runner_vm agent credential — what a SCHEDULE pins as
994
+ // assigned_agent_credential_id (a different id-space from `id` = compute_configs._id).
995
+ ...(typeof r['credential_id'] === 'string' && r['credential_id'] ? { credentialId: r['credential_id'] } : {}),
996
+ }));
997
+ }
998
+ /**
999
+ * LOCAL agents registered to the project + live online status
1000
+ * (`GET /api/agent-credentials/project-agents`) — the same live roster resolveLocalAgentCredential
1001
+ * reads, exposed so a client can list + pick an agent (`id` = credential_id) before a LOCAL run.
1002
+ * A shared project lists EVERY member's agent; `owner`/`online` let the user pick the right one.
1003
+ */
1004
+ async listLocalAgents() {
1005
+ const res = await this.http.getJson('/api/agent-credentials/project-agents');
1006
+ return (res.agents ?? [])
1007
+ .filter((a) => a.credential_id)
1008
+ .map((a) => {
1009
+ const name = a.owner_name || a.host_name;
1010
+ return {
1011
+ id: String(a.credential_id),
1012
+ online: a.is_online === true,
1013
+ ...(name ? { name } : {}),
1014
+ // Machine hostname — kept ALONGSIDE the owner so the UI/list can disambiguate several
1015
+ // agents owned by the same person (e.g. two "rishi" agents on different machines).
1016
+ ...(a.host_name ? { host: a.host_name } : {}),
1017
+ ...(typeof a.agent_status === 'string' ? { status: a.agent_status } : {}),
1018
+ ...(a.owner_name ? { owner: a.owner_name } : {}),
1019
+ };
1020
+ });
1021
+ }
1022
+ /**
1023
+ * Valid action vocabulary per technology. android/api/database/mainframe always
1024
+ * come from the static `WALNUT_ACTIONS` (there is no live/per-org source for
1025
+ * them); `web` is replaced by the live per-org `GET /api/actions` catalog when
1026
+ * that call succeeds and returns at least one action, else falls back to the
1027
+ * static list. No retry, no caching.
1028
+ */
1029
+ async actionsCatalog(platform) {
1030
+ const catalog = { ...WALNUT_ACTIONS };
1031
+ try {
1032
+ const res = await this.http.getJson('/api/actions');
1033
+ const live = (res.actions ?? [])
1034
+ .map((a) => a.action_type ?? a.name)
1035
+ .filter((v) => typeof v === 'string' && v.length > 0);
1036
+ if (live.length)
1037
+ catalog.web = live;
1038
+ }
1039
+ catch {
1040
+ // GET /api/actions unavailable (network error, 404, 500, ...) -- keep the
1041
+ // static WALNUT_ACTIONS.web fallback.
1042
+ }
1043
+ const all = actionDefsFrom(catalog);
1044
+ return platform ? all.filter((a) => a.platform === platform) : all;
1045
+ }
1046
+ // --- Agent-bound recording surface (composite delegation) -----------------
1047
+ // Recording (+ save-with-editor) is the ONLY surface that needs a local runtime, so it is NOT done
1048
+ // over direct REST: it hops to the LOCAL AGENT's /mcp (cfg.url) via an internal RemoteMcpAdapter.
1049
+ // The recording passthrough (tools/recording.tools.ts) drives it through `listAgentTools`/`callRaw`,
1050
+ // and tc_automate (tools/testcase.tools.ts) calls `callRaw('web'/'web_status')`. When no agent /mcp
1051
+ // is configured (a cloud editor), recording is gracefully unavailable.
1052
+ /** The internal hop to the local agent's /mcp, built lazily and ONLY when cfg.url is set. */
1053
+ hop() {
1054
+ if (!this.cfg.url)
1055
+ return null;
1056
+ if (!this.agentHop)
1057
+ this.agentHop = new RemoteMcpAdapter(this.cfg);
1058
+ return this.agentHop;
1059
+ }
1060
+ /** The local agent's advertised tools (web/mainframe/…), mirrored so the recording passthrough can
1061
+ * register them. No agent configured (cloud editor) → `[]`, so no recording tools get registered
1062
+ * and recording is gracefully unavailable. */
1063
+ async listAgentTools() {
1064
+ const hop = this.hop();
1065
+ return hop ? hop.listAgentTools() : [];
1066
+ }
1067
+ /** Forward an agent-bound tool call (recording) to the local agent's /mcp. Without a configured
1068
+ * agent /mcp (cloud editor) there is nothing to forward to, so this fails with a clear instruction. */
1069
+ async callRaw(name, args) {
1070
+ const hop = this.hop();
1071
+ if (!hop) {
1072
+ throw new Error("Recording needs a local Walnut agent — set QA_MCP_URL to the agent's /mcp endpoint.");
1073
+ }
1074
+ return hop.callRaw(name, args);
1075
+ }
1076
+ // Recording JOURNAL (read/edit) is agent-bound too — it derives from a local recording seed the
1077
+ // agent owns (backend journal persistence is the deferred Item 3b). So journal_grep/journal_modify
1078
+ // DELEGATE to the local agent's /mcp, exactly like recording. No agent (a cloud editor) → a clear
1079
+ // "needs a local agent" error. The methods are always PRESENT so discovery.tools' `!adapter.readJournal`
1080
+ // guard doesn't short-circuit to "no journal" on the direct path — the requirement is the hop, not absence.
1081
+ async readJournal(tcId) {
1082
+ const hop = this.hop();
1083
+ if (!hop)
1084
+ throw new Error("Reading a test case's recording journal needs a local product agent — set QA_MCP_URL to the agent's /mcp endpoint.");
1085
+ return hop.readJournal(tcId);
1086
+ }
1087
+ async editJournal(tcId, ops) {
1088
+ const hop = this.hop();
1089
+ if (!hop)
1090
+ throw new Error("Editing a test case's recording journal (journal_modify) needs a local product agent — set QA_MCP_URL to the agent's /mcp endpoint.");
1091
+ return hop.editJournal(tcId, ops);
1092
+ }
1093
+ /** Deep link into the product UI (e.g. `/test-cases/:id`, `/reports/:id`). These routes are served
1094
+ * by walnut-frontend, NOT the API host, so this must use the FRONTEND origin. */
1095
+ productUrlFor(entity) {
1096
+ return `${this.resolveUiOrigin()}/${PRODUCT_URL_SEGMENT[entity.kind]}/${entity.id}`;
1097
+ }
1098
+ /** Frontend origin for deep links, resolved (and trailing-slash-trimmed) as:
1099
+ * 1. `walnutUiOrigin` (env `QA_MCP_WALNUT_UI`) -- the explicit override; point it at the local
1100
+ * frontend dev server now, and the deployed frontend once shipped.
1101
+ * 2. else the BACKEND base (`cfg.baseUrl` / `QA_BASE_URL`) with a trailing `/api` stripped --
1102
+ * correct for a single-host deploy (https://app.walnutai.ai/api -> https://app.walnutai.ai), so
1103
+ * production Just Works with no override. A split local dev host (backend :8080, Vite :5173)
1104
+ * can't be derived, so it needs (1). NOTE: derived from `baseUrl`, NOT `cfg.url` — `cfg.url` is
1105
+ * now the agent /mcp endpoint, not a web origin. */
1106
+ resolveUiOrigin() {
1107
+ const strip = (s) => s.replace(/\/+$/, '');
1108
+ const override = this.cfg.walnutUiOrigin?.trim();
1109
+ if (override)
1110
+ return strip(override);
1111
+ return strip(this.cfg.baseUrl ?? '').replace(/\/api$/, '');
1112
+ }
1113
+ }
1114
+ //# sourceMappingURL=adapter.js.map