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