yaml-flow 8.5.3 → 8.6.2

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 (176) hide show
  1. package/browser/asset-integrity.json +3 -3
  2. package/examples/board/demo-shell-with-server.html +2 -2
  3. package/examples/board/doc.html +2 -2
  4. package/examples/board/server/board-server.js +773 -13
  5. package/examples/board/server/board-worker/task-executor.js +166 -51
  6. package/examples/board/server/chat-flow/copilot-chat/assistant.js +25 -12
  7. package/examples/board/server/chat-flow/copilot-chat/probe.js +7 -0
  8. package/examples/board/server/chat-flow/copilot-chat/shared.js +97 -0
  9. package/examples/board/server/chat-flow/flow-steps.json +109 -51
  10. package/examples/board/server-config.json +2 -0
  11. package/examples/board/test/server-http-test.js +878 -67
  12. package/examples/board-local/demo-shell-localstorage.html +3 -3
  13. package/lib/{artifacts-store-lib-CVgtQrNZ.d.cts → artifacts-store-lib-BR-Samty.d.cts} +1 -1
  14. package/lib/{artifacts-store-lib-D-k-E8Vy.d.ts → artifacts-store-lib-DT7XlWUL.d.ts} +1 -1
  15. package/lib/artifacts-store-public.cjs +1 -1
  16. package/lib/artifacts-store-public.d.cts +3 -3
  17. package/lib/artifacts-store-public.d.ts +3 -3
  18. package/lib/artifacts-store-public.js +1 -1
  19. package/lib/batch/index.cjs +1 -1
  20. package/lib/batch/index.js +1 -1
  21. package/lib/board-live-cards-mcp.cjs +1 -1
  22. package/lib/board-live-cards-mcp.d.cts +87 -34
  23. package/lib/board-live-cards-mcp.d.ts +87 -34
  24. package/lib/board-live-cards-mcp.js +1 -1
  25. package/lib/board-live-cards-node.cjs +8 -16
  26. package/lib/board-live-cards-node.d.cts +52 -14
  27. package/lib/board-live-cards-node.d.ts +52 -14
  28. package/lib/board-live-cards-node.js +8 -16
  29. package/lib/{board-live-cards-public-BGS22cMb.d.ts → board-live-cards-public-BMUIPOrc.d.ts} +90 -30
  30. package/lib/board-live-cards-public-async-DKZqbJVU.d.ts +256 -0
  31. package/lib/board-live-cards-public-async-dMWNbWq6.d.cts +256 -0
  32. package/lib/{board-live-cards-public-B13InXhC.d.cts → board-live-cards-public-wkNmBIRC.d.cts} +90 -30
  33. package/lib/board-live-cards-public.cjs +1 -2
  34. package/lib/board-live-cards-public.d.cts +2 -2
  35. package/lib/board-live-cards-public.d.ts +2 -2
  36. package/lib/board-live-cards-public.js +1 -2
  37. package/lib/board-live-cards-server-runtime.cjs +1 -7
  38. package/lib/board-live-cards-server-runtime.d.cts +7 -6
  39. package/lib/board-live-cards-server-runtime.d.ts +7 -6
  40. package/lib/board-live-cards-server-runtime.js +1 -7
  41. package/lib/board-livegraph-runtime/index.cjs +1 -2
  42. package/lib/board-livegraph-runtime/index.js +1 -2
  43. package/lib/board-worker-adapter.cjs +22 -7
  44. package/lib/board-worker-adapter.d.cts +28 -3
  45. package/lib/board-worker-adapter.d.ts +28 -3
  46. package/lib/board-worker-adapter.js +22 -7
  47. package/lib/card-compute/index.cjs +1 -9
  48. package/lib/card-compute/index.js +1 -9
  49. package/lib/card-store-public.cjs +1 -1
  50. package/lib/card-store-public.d.cts +2 -2
  51. package/lib/card-store-public.d.ts +2 -2
  52. package/lib/card-store-public.js +1 -1
  53. package/lib/card-validation.cjs +1 -9
  54. package/lib/card-validation.js +1 -9
  55. package/lib/{chat-storage-lib-0imhRX3l.d.cts → chat-storage-lib-BIUbE-fM.d.cts} +1 -1
  56. package/lib/{chat-storage-lib-CJn7a6OH.d.ts → chat-storage-lib-BlG-sobS.d.ts} +1 -1
  57. package/lib/chat-store-public.cjs +1 -1
  58. package/lib/chat-store-public.d.cts +3 -3
  59. package/lib/chat-store-public.d.ts +3 -3
  60. package/lib/chat-store-public.js +1 -1
  61. package/lib/chunk-2MZUYY65.cjs +2 -0
  62. package/lib/chunk-5EA2ESS4.cjs +16 -0
  63. package/lib/chunk-76ON3V7R.js +2 -0
  64. package/lib/chunk-7BKNHFNH.js +2 -0
  65. package/lib/chunk-BQS3EIEK.js +3 -0
  66. package/lib/chunk-CIAJNUR4.js +2 -0
  67. package/lib/chunk-DAXACY63.js +2 -0
  68. package/lib/chunk-FW4363Y4.js +2 -0
  69. package/lib/chunk-FZ2SBU5M.js +3 -0
  70. package/lib/chunk-G4XXRHL2.cjs +3 -0
  71. package/lib/chunk-GJJMEAVN.cjs +2 -0
  72. package/lib/chunk-GNFE24S7.cjs +2 -0
  73. package/lib/chunk-GYQXDNNI.cjs +2 -0
  74. package/lib/chunk-H5HBXPOI.cjs +3 -0
  75. package/lib/chunk-H5KD3JPY.cjs +2 -0
  76. package/lib/chunk-HEEDJEKM.js +2 -0
  77. package/lib/chunk-HLJH7LGW.js +16 -0
  78. package/lib/chunk-IXZG74EW.cjs +2 -0
  79. package/lib/chunk-JAL25FGA.cjs +2 -0
  80. package/lib/chunk-JM5EKT57.js +2 -0
  81. package/lib/chunk-JMDHDY6M.js +2 -0
  82. package/lib/chunk-KBELAKIY.js +2 -0
  83. package/lib/chunk-KHJABJ45.cjs +3 -0
  84. package/lib/chunk-KLRUISRY.cjs +2 -0
  85. package/lib/chunk-KQX6R4PV.cjs +8 -0
  86. package/lib/chunk-LODXIALE.cjs +2 -0
  87. package/lib/chunk-MLVTJASJ.js +2 -0
  88. package/lib/chunk-MNEOJWPS.js +10 -0
  89. package/lib/chunk-N6P2JW4W.js +3 -0
  90. package/lib/chunk-NMZ6XNLB.cjs +3 -0
  91. package/lib/chunk-OEFTOO47.cjs +3 -0
  92. package/lib/chunk-OPNGCSXJ.js +2 -0
  93. package/lib/chunk-OSWJKJLB.js +8 -0
  94. package/lib/chunk-P7ZCDICS.cjs +2 -0
  95. package/lib/chunk-PBCDDO4V.cjs +2 -0
  96. package/lib/chunk-PMUSJQSR.cjs +2 -0
  97. package/lib/chunk-Q6H7NINN.cjs +5 -0
  98. package/lib/chunk-QWBNDVUA.js +5 -0
  99. package/lib/chunk-S6DRP2HX.cjs +2 -0
  100. package/lib/chunk-SCWHDI3I.js +2 -0
  101. package/lib/chunk-SFVO2LB2.cjs +3 -0
  102. package/lib/chunk-U2N6MCD5.cjs +2 -0
  103. package/lib/chunk-UJ7ZTV4J.cjs +10 -0
  104. package/lib/chunk-VGT3TRQG.js +3 -0
  105. package/lib/chunk-VLBB3D6B.js +3 -0
  106. package/lib/chunk-VMW4Z6EF.js +3 -0
  107. package/lib/chunk-WDPOGXTY.js +2 -0
  108. package/lib/chunk-WOALA3V5.cjs +2 -0
  109. package/lib/chunk-X3LC4LII.js +2 -0
  110. package/lib/chunk-XQRNDX4Q.js +2 -0
  111. package/lib/chunk-YGKDQLYP.js +2 -0
  112. package/lib/chunk-YMEIPKLW.cjs +2 -0
  113. package/lib/cloud-storage.cjs +2 -0
  114. package/lib/cloud-storage.d.cts +177 -0
  115. package/lib/cloud-storage.d.ts +177 -0
  116. package/lib/cloud-storage.js +2 -0
  117. package/lib/config/index.cjs +1 -1
  118. package/lib/config/index.js +1 -1
  119. package/lib/continuous-event-graph/index.cjs +1 -2
  120. package/lib/continuous-event-graph/index.js +1 -2
  121. package/lib/event-graph/index.cjs +1 -22
  122. package/lib/event-graph/index.js +1 -22
  123. package/lib/execution-refs.cjs +1 -2
  124. package/lib/execution-refs.d.cts +3 -2
  125. package/lib/execution-refs.d.ts +3 -2
  126. package/lib/execution-refs.js +1 -2
  127. package/lib/index.cjs +2 -24
  128. package/lib/index.d.cts +1 -1
  129. package/lib/index.d.ts +1 -1
  130. package/lib/index.js +2 -24
  131. package/lib/{types-CIgsh56O.d.cts → queue-lane-registry-BPKWWgd4.d.cts} +66 -14
  132. package/lib/{types-30R357js.d.ts → queue-lane-registry-Be6c0ftj.d.ts} +66 -14
  133. package/lib/server-runtime/index.cjs +1 -7
  134. package/lib/server-runtime/index.d.cts +18 -7
  135. package/lib/server-runtime/index.d.ts +18 -7
  136. package/lib/server-runtime/index.js +1 -7
  137. package/lib/step-machine/index.cjs +1 -11
  138. package/lib/step-machine/index.js +1 -11
  139. package/lib/step-machine-public/index.cjs +1 -4
  140. package/lib/step-machine-public/index.d.cts +1 -1
  141. package/lib/step-machine-public/index.d.ts +1 -1
  142. package/lib/step-machine-public/index.js +1 -4
  143. package/lib/{storage-interface-B2WD9D5n.d.cts → storage-interface-BFiD3kyB.d.cts} +38 -1
  144. package/lib/{storage-interface-B2WD9D5n.d.ts → storage-interface-BFiD3kyB.d.ts} +38 -1
  145. package/lib/stores/index.cjs +1 -2
  146. package/lib/stores/index.d.cts +1 -1
  147. package/lib/stores/index.d.ts +1 -1
  148. package/lib/stores/index.js +1 -2
  149. package/lib/stores/kv.cjs +1 -2
  150. package/lib/stores/kv.d.cts +1 -1
  151. package/lib/stores/kv.d.ts +1 -1
  152. package/lib/stores/kv.js +1 -2
  153. package/lib/stores/memory.cjs +1 -1
  154. package/lib/stores/memory.js +1 -1
  155. package/package.json +7 -16
  156. package/cli/board-live-cards-lib-COi4bSpk.d.ts +0 -322
  157. package/cli/browser-api/board-live-cards-browser-adapter.d.ts +0 -36
  158. package/cli/browser-api/board-live-cards-browser-adapter.js +0 -4
  159. package/cli/browser-api/card-store-browser-api.d.ts +0 -25
  160. package/cli/browser-api/card-store-browser-api.js +0 -2
  161. package/cli/browser-api/jsonata-sync.cjs +0 -7623
  162. package/cli/bundled/artifacts-store-cli.mjs +0 -12
  163. package/cli/bundled/batch-runner-cli.mjs +0 -3
  164. package/cli/bundled/board-live-cards-cli.mjs +0 -29
  165. package/cli/bundled/card-store-cli.mjs +0 -154
  166. package/cli/bundled/chat-store-cli.mjs +0 -16
  167. package/cli/bundled/jsonata-sync.cjs +0 -7623
  168. package/cli/bundled/step-machine-cli.mjs +0 -150
  169. package/cli/execution-interface-BCIhu1gO.d.ts +0 -442
  170. package/cli/types-H3EMBPY2.d.ts +0 -398
  171. package/examples/board/server/README-mcp-api.md +0 -690
  172. package/examples/board/test/server-http-mcp-test.js +0 -1280
  173. package/lib/board-livegraph-runtime/jsonata-sync.cjs +0 -7623
  174. package/lib/card-compute/jsonata-sync.cjs +0 -7623
  175. package/lib/continuous-event-graph/jsonata-sync.cjs +0 -7623
  176. package/lib/server-runtime/jsonata-sync.cjs +0 -7623
@@ -34,6 +34,8 @@ const cliPort = portArg !== -1 ? parseInt(cliArgs[portArg + 1], 10) : NaN;
34
34
  const skipT1 = cliArgs.includes('--skip-t1');
35
35
  const skipT2 = cliArgs.includes('--skip-t2');
36
36
  const skipT3 = cliArgs.includes('--skip-t3');
37
+ const skipT4 = cliArgs.includes('--skip-t4');
38
+ const skipT5 = cliArgs.includes('--skip-t5');
37
39
  function isCopilotAvailable() {
38
40
  try {
39
41
  const r = spawnSync('copilot', ['--version'], { timeout: 5_000, stdio: 'ignore', windowsHide: true });
@@ -94,6 +96,7 @@ const NS = {
94
96
  statusGeneration: 0,
95
97
  computedValues: {},
96
98
  chatEvents: [],
99
+ boardEvents: [],
97
100
  };
98
101
 
99
102
  function applyFrame(payload) {
@@ -126,6 +129,9 @@ function applyFrame(payload) {
126
129
  if (n && n.kind === 'computed_values' && n.cardId) {
127
130
  NS.computedValues[n.cardId] = n.values;
128
131
  }
132
+ if (n && (n.kind === 'card_removed' || n.kind === 'card_refreshed') && n.cardId) {
133
+ NS.boardEvents.push({ kind: n.kind, cardId: n.cardId, at: Date.now() });
134
+ }
129
135
  }
130
136
  }
131
137
  }
@@ -200,6 +206,10 @@ function assert(condition, message) {
200
206
  }
201
207
  }
202
208
 
209
+ function randomTurnId() {
210
+ return String(Math.floor(100000 + Math.random() * 900000));
211
+ }
212
+
203
213
  function waitUntil(predicate, timeoutMs, label) {
204
214
  return new Promise((resolve, reject) => {
205
215
  const deadline = Date.now() + timeoutMs;
@@ -219,6 +229,43 @@ function waitUntil(predicate, timeoutMs, label) {
219
229
  });
220
230
  }
221
231
 
232
+ function wait(ms) {
233
+ return new Promise((resolve) => setTimeout(resolve, ms));
234
+ }
235
+
236
+ async function stopChildProcess(proc, label) {
237
+ if (!proc) return;
238
+ if (proc.exitCode !== null) return;
239
+
240
+ const exitPromise = new Promise((resolve) => {
241
+ proc.once('exit', (code, signal) => resolve({ code, signal }));
242
+ });
243
+
244
+ try {
245
+ proc.kill();
246
+ } catch {
247
+ return;
248
+ }
249
+
250
+ const gracefulExit = await Promise.race([
251
+ exitPromise,
252
+ wait(5_000).then(() => null),
253
+ ]);
254
+ if (gracefulExit) return;
255
+
256
+ if (proc.exitCode === null) {
257
+ try { proc.kill('SIGKILL'); } catch { /* ignore */ }
258
+ }
259
+
260
+ const forcedExit = await Promise.race([
261
+ exitPromise,
262
+ wait(5_000).then(() => null),
263
+ ]);
264
+ if (!forcedExit && proc.exitCode === null) {
265
+ throw new Error(`${label} did not exit after kill()`);
266
+ }
267
+ }
268
+
222
269
  const waitForInitialPayload = (ms = 15_000) =>
223
270
  waitUntil(() => NS.initialPayload || false, ms, 'initial SSE payload');
224
271
 
@@ -330,6 +377,45 @@ function httpJson(method, url, payload) {
330
377
  });
331
378
  }
332
379
 
380
+ function httpMcp(tool, args) {
381
+ return httpJson('POST', `${BASE}/mcp`, { tool, args });
382
+ }
383
+
384
+ function httpMcpControlplane(tool, args) {
385
+ return httpJson('POST', `${BASE}/mcp-controlplane`, { tool, args });
386
+ }
387
+
388
+ function httpMcpRaw(tool, args) {
389
+ return new Promise((resolve, reject) => {
390
+ const u = new URL(`${BASE}/mcp-raw`);
391
+ const data = JSON.stringify({ tool, args });
392
+ const opts = {
393
+ hostname: u.hostname,
394
+ port: u.port,
395
+ path: u.pathname,
396
+ method: 'POST',
397
+ headers: {
398
+ 'Content-Type': 'application/json',
399
+ 'Content-Length': Buffer.byteLength(data),
400
+ },
401
+ };
402
+ const req = http.request(opts, (res) => {
403
+ const chunks = [];
404
+ res.on('data', c => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); });
405
+ res.on('end', () => {
406
+ resolve({
407
+ status: res.statusCode,
408
+ body: Buffer.concat(chunks),
409
+ headers: res.headers,
410
+ });
411
+ });
412
+ });
413
+ req.on('error', reject);
414
+ req.write(data);
415
+ req.end();
416
+ });
417
+ }
418
+
333
419
  function httpUploadChatFile(url, fileName, content, contentType = 'text/plain; charset=utf-8') {
334
420
  return new Promise((resolve, reject) => {
335
421
  const u = new URL(url);
@@ -359,6 +445,16 @@ function httpUploadChatFile(url, fileName, content, contentType = 'text/plain; c
359
445
  });
360
446
  }
361
447
 
448
+ function deepCloneJson(value) {
449
+ return JSON.parse(JSON.stringify(value));
450
+ }
451
+
452
+ function expectMcpSuccess(httpResult, label) {
453
+ assert(httpResult.status === 200, `${label} returned ${httpResult.status}`);
454
+ assert(httpResult.data?.status === 'success', `${label} expected status=success, got ${JSON.stringify(httpResult.data)}`);
455
+ return httpResult.data.data;
456
+ }
457
+
362
458
  function startServer(port) {
363
459
  return new Promise((resolve, reject) => {
364
460
  const proc = spawn(process.execPath, [SERVER_SCRIPT], {
@@ -370,6 +466,7 @@ function startServer(port) {
370
466
  DEMO_SETUP_DIR: SETUP_DIR,
371
467
  DEMO_BOARD_SETUP_ROOT: BOARD_SETUP_ROOT,
372
468
  DEMO_CARDS_PATTERN: CARD_PATTERN,
469
+ BOARD_SERVER_ENABLE_TEST_REQ: '1',
373
470
  },
374
471
  });
375
472
  let ready = false;
@@ -388,9 +485,10 @@ function startServer(port) {
388
485
  if (!ready) reject(new Error(`Server exited early: code ${code}`));
389
486
  });
390
487
 
391
- setTimeout(() => {
488
+ const startupTimer = setTimeout(() => {
392
489
  if (!ready) reject(new Error('Server startup timeout (15s)'));
393
490
  }, 15_000);
491
+ startupTimer.unref?.();
394
492
  });
395
493
  }
396
494
 
@@ -449,6 +547,16 @@ try {
449
547
  assert(statusRes.status === 200, `board-status returned ${statusRes.status}`);
450
548
  const httpSummary = statusRes.data?.statusSnapshot?.summary;
451
549
  assert(httpSummary, 'statusSnapshot.summary missing from board-status');
550
+ const statusMcpRes = await httpMcp('inspect.board-runtime-status', {});
551
+ assert(statusMcpRes.status === 200, `inspect.board-runtime-status returned ${statusMcpRes.status}`);
552
+ assert(statusMcpRes.data?.status === 'success', `inspect.board-runtime-status failed: ${JSON.stringify(statusMcpRes.data)}`);
553
+ const mcpSummary = statusMcpRes.data?.data?.summary;
554
+ assert(mcpSummary, 'summary missing from inspect.board-runtime-status');
555
+ const comparableStatusKeys = ['card_count', 'completed', 'eligible', 'pending', 'blocked', 'in_progress', 'failed', 'unresolved'];
556
+ const httpComparableSummary = Object.fromEntries(comparableStatusKeys.map((key) => [key, httpSummary[key]]));
557
+ const mcpComparableSummary = Object.fromEntries(comparableStatusKeys.map((key) => [key, mcpSummary[key]]));
558
+ assert(JSON.stringify(httpComparableSummary) === JSON.stringify(mcpComparableSummary),
559
+ `HTTP board-status summary mismatch vs MCP summary: http=${JSON.stringify(httpComparableSummary)} mcp=${JSON.stringify(mcpComparableSummary)}`);
452
560
  assert(httpSummary.completed === httpSummary.card_count, `not all complete: ${JSON.stringify(httpSummary)}`);
453
561
  console.log(`[T0.4] board-status: ${JSON.stringify(httpSummary)}`);
454
562
 
@@ -461,12 +569,14 @@ try {
461
569
  if (skipT1) {
462
570
  console.log('\n=== T1: skipped (--skip-t1) ===');
463
571
  } else {
464
- console.log('\n=== T1: patch holdings (+1 row) ===');
465
-
466
- // Read current holdings from card store
467
- const portfolioCardRes = await httpGet(`${BASE}/cards/card-portfolio`);
468
- assert(portfolioCardRes.status === 200, `GET card-portfolio returned ${portfolioCardRes.status}`);
469
- const existingHoldings = portfolioCardRes.data?.card_data?.holdings;
572
+ console.log('\n=== T1: local mutation + manage.upsert-card (+1 row) ===');
573
+
574
+ // Read the live card document via inspect.card-definition-and-runtime before preparing the upsert payload.
575
+ const portfolioCardRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: 'card-portfolio' });
576
+ assert(portfolioCardRes.status === 200, `inspect.card-definition-and-runtime returned ${portfolioCardRes.status}`);
577
+ assert(portfolioCardRes.data?.status === 'success', `inspect.card-definition-and-runtime failed: ${JSON.stringify(portfolioCardRes.data)}`);
578
+ const existingCard = portfolioCardRes.data?.data?.card_definition_and_static_data ?? null;
579
+ const existingHoldings = existingCard?.card_data?.holdings;
470
580
  assert(Array.isArray(existingHoldings), 'card-portfolio.card_data.holdings missing');
471
581
  const t0HoldingsCount = existingHoldings.length;
472
582
  const t0PositionsCount = t0Positions.length;
@@ -480,19 +590,31 @@ try {
480
590
  const newTicker = available[0];
481
591
 
482
592
  const newHoldings = [...existingHoldings, { ticker: newTicker, quantity: 1, cost_basis: 100 }];
483
- const patchRes = await httpJson('PATCH', `${BASE}/cards/card-portfolio`, { card_data: { holdings: newHoldings } });
484
- assert(patchRes.status === 200, `PATCH card-portfolio returned ${patchRes.status}`);
593
+ const nextCard = {
594
+ ...existingCard,
595
+ card_data: {
596
+ ...(existingCard?.card_data || {}),
597
+ holdings: newHoldings,
598
+ },
599
+ };
600
+ const upsertRes = await httpMcp('manage.upsert-card', {
601
+ card_id: 'card-portfolio',
602
+ candidate_card_content: nextCard,
603
+ });
604
+ assert(upsertRes.status === 200, `manage.upsert-card returned ${upsertRes.status}`);
605
+ assert(upsertRes.data?.status === 'success', `manage.upsert-card failed: ${JSON.stringify(upsertRes.data)}`);
485
606
 
486
- // Wait for re-completion after the patch triggers a new cycle
607
+ // Wait for re-completion after the upsert triggers a new cycle
487
608
  NS.statusSummary = null;
488
609
  await new Promise(r => setTimeout(r, 4000));
489
- const t1Summary = await waitForAllCompleted(30_000, 'T1 holdings patch');
610
+ const t1Summary = await waitForAllCompleted(30_000, 'T1 holdings upsert');
490
611
  assert(t1Summary.failed === 0, `T1 failed=${t1Summary.failed}`);
491
612
 
492
- // Verify holdings +1 from card store
493
- const t1PortfolioRes = await httpGet(`${BASE}/cards/card-portfolio`);
494
- assert(t1PortfolioRes.status === 200, `GET card-portfolio after patch returned ${t1PortfolioRes.status}`);
495
- const afterHoldings = t1PortfolioRes.data?.card_data?.holdings;
613
+ // Verify holdings +1 from the live card document after upsert.
614
+ const t1PortfolioRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: 'card-portfolio' });
615
+ assert(t1PortfolioRes.status === 200, `inspect.card-definition-and-runtime after upsert returned ${t1PortfolioRes.status}`);
616
+ assert(t1PortfolioRes.data?.status === 'success', `inspect.card-definition-and-runtime after upsert failed: ${JSON.stringify(t1PortfolioRes.data)}`);
617
+ const afterHoldings = t1PortfolioRes.data?.data?.card_definition_and_static_data?.card_data?.holdings;
496
618
  const afterHoldingsCount = Array.isArray(afterHoldings) ? afterHoldings.length : 0;
497
619
 
498
620
  // Verify positions +1 from computed_values captured via SSE
@@ -548,6 +670,48 @@ try {
548
670
  const t2DownloadedText = t2DownloadRes.body.toString('utf-8');
549
671
  assert(t2DownloadedText === t2UploadText, 'T2 downloaded content mismatch');
550
672
  console.log('[T2] ok: card_data.files updated and file download endpoint returned exact bytes');
673
+
674
+ console.log('\n=== T2a: MCP controlplane file upload -> MCP raw file download ===');
675
+ const t2aCardBefore = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
676
+ assert(t2aCardBefore.status === 200, `T2a pre card read returned ${t2aCardBefore.status}`);
677
+ assert(t2aCardBefore.data?.status === 'success', `T2a pre card read failed: ${JSON.stringify(t2aCardBefore.data)}`);
678
+ const t2aCardBeforeObj = Array.isArray(t2aCardBefore.data?.data) ? t2aCardBefore.data.data[0] : null;
679
+ const t2aFilesBefore = Array.isArray(t2aCardBeforeObj?.card_data?.files) ? t2aCardBeforeObj.card_data.files : [];
680
+ const t2aBeforeCount = t2aFilesBefore.length;
681
+
682
+ const t2aUploadText = `mcp-file-upload-${Date.now()}`;
683
+ const t2aUploadName = 't2a-upload.txt';
684
+ const t2aUploadRes = await httpMcpControlplane('manage.upload-card-file', {
685
+ board_id: BOARD_ID,
686
+ card_id: T2_FILE_CARD_ID,
687
+ file_name: t2aUploadName,
688
+ content_type: 'text/plain; charset=utf-8',
689
+ text: t2aUploadText,
690
+ });
691
+ assert(t2aUploadRes.status === 200, `T2a file upload returned ${t2aUploadRes.status}`);
692
+ assert(t2aUploadRes.data?.status === 'success', `T2a file upload failed: ${JSON.stringify(t2aUploadRes.data)}`);
693
+ const t2aUploadedFile = t2aUploadRes.data?.data?.file;
694
+ assert(t2aUploadedFile && typeof t2aUploadedFile === 'object', 'T2a upload response missing file metadata');
695
+ assert(String(t2aUploadedFile?.name || '') === t2aUploadName, 'T2a uploaded file name mismatch');
696
+
697
+ const t2aCardAfter = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
698
+ assert(t2aCardAfter.status === 200, `T2a post card read returned ${t2aCardAfter.status}`);
699
+ assert(t2aCardAfter.data?.status === 'success', `T2a post card read failed: ${JSON.stringify(t2aCardAfter.data)}`);
700
+ const t2aCardAfterObj = Array.isArray(t2aCardAfter.data?.data) ? t2aCardAfter.data.data[0] : null;
701
+ const t2aFilesAfter = Array.isArray(t2aCardAfterObj?.card_data?.files) ? t2aCardAfterObj.card_data.files : [];
702
+ assert(t2aFilesAfter.length === t2aBeforeCount + 1, `T2a expected files +1 (before=${t2aBeforeCount}, after=${t2aFilesAfter.length})`);
703
+
704
+ const t2aFileIndex = t2aFilesAfter.findIndex((f) => String(f?.stored_name || '') === String(t2aUploadedFile?.stored_name || ''));
705
+ assert(t2aFileIndex >= 0, 'T2a uploaded file metadata not found in card_data.files');
706
+
707
+ const t2aDownloadRes = await httpMcpRaw('inspect.file-contents', {
708
+ card_id: T2_FILE_CARD_ID,
709
+ file_idx: t2aFileIndex,
710
+ });
711
+ assert(t2aDownloadRes.status === 200, `T2a file download returned ${t2aDownloadRes.status}`);
712
+ const t2aDownloadedText = t2aDownloadRes.body.toString('utf-8');
713
+ assert(t2aDownloadedText === t2aUploadText, 'T2a downloaded content mismatch');
714
+ console.log('[T2a] ok: mcp-controlplane upload and mcp-raw download returned exact bytes');
551
715
  }
552
716
 
553
717
  // ── T3*: chat protocol over API + SSE ──
@@ -555,55 +719,90 @@ try {
555
719
  if (skipT3) {
556
720
  console.log('\n=== T3: skipped (--skip-t3) ===');
557
721
  } else {
558
- console.log(`\n[${new Date().toISOString()}] === T3: probe chat protocol (SSE lifecycle) ===`);
559
- chatSseClientId = `chat-proto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
560
- chatSseClient = startSseClient(`${BASE}/sse?clientId=${encodeURIComponent(chatSseClientId)}`, (payload) => {
561
- captureChatEvents(payload, CHAT_CARD_ID);
562
- });
563
- await new Promise((r) => setTimeout(r, 400));
564
-
565
- const subRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/subscribe-sse`, { clientId: chatSseClientId });
566
- assert(subRes.status === 200, `chat subscribe returned ${subRes.status}`);
567
-
568
- const t2Before = await httpGet(`${BASE}/cards/${CHAT_CARD_ID}/chats?all-turns=true`);
569
- assert(t2Before.status === 200, `T3 pre chats returned ${t2Before.status}`);
570
- const t2BeforeMessages = Array.isArray(t2Before.data?.messages) ? t2Before.data.messages : [];
571
- const t2BeforeCount = t2BeforeMessages.length;
572
- const t2EventStart = NS.chatEvents.length;
573
- const t2ProbePrompt = `Probe protocol validation ${Date.now()}`;
574
-
575
- const t2SendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
576
- actionType: 'chat-send',
577
- payload: {
578
- text: `${ECHO_PROBE_MARKER}${t2ProbePrompt}${ECHO_PROBE_MARKER}`,
579
- },
580
- });
581
- assert(t2SendRes.status === 200, `T3 chat-send returned ${t2SendRes.status}`);
582
-
583
- const t2Lifecycle = await waitForChatPredicate((events) => {
584
- return matchOrderedProbeLifecycle(events.slice(t2EventStart), {
585
- beforeCount: t2BeforeCount,
586
- beforeProcessing: false,
587
- prompt: t2ProbePrompt,
588
- inProgressText: PROBE_IN_PROGRESS_TEXT,
589
- });
590
- }, 45_000, 'T3 ordered lifecycle');
591
- assert(!!t2Lifecycle, 'T3 ordered lifecycle not observed');
592
-
593
- const t2After = await httpGet(`${BASE}/cards/${CHAT_CARD_ID}/chats?all-turns=true`);
594
- assert(t2After.status === 200, `T3 post chats returned ${t2After.status}`);
595
- const t2AfterMessages = Array.isArray(t2After.data?.messages) ? t2After.data.messages : [];
596
- const t2NewMessages = t2AfterMessages.slice(t2BeforeCount);
597
- assert(t2NewMessages.length >= 3, `T3 expected at least 3 new chat messages, got ${t2NewMessages.length}`);
598
- const t2User = t2NewMessages.find((m) => m?.role === 'user');
599
- const t2InProgress = t2NewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
600
- const t2AssistantMsg = t2NewMessages.find((m) => m?.role === 'assistant');
601
- assert(!!t2User && typeof t2User.id === 'string', 'T3 user chat message missing id');
602
- assert(String(t2User?.text || '').includes(t2ProbePrompt), 'T3 user file text mismatch');
603
- assert(!!t2InProgress && typeof t2InProgress.id === 'string', 'T3 in-progress system message missing id');
604
- assert(!!t2AssistantMsg && typeof t2AssistantMsg.id === 'string', 'T3 assistant chat message missing id');
605
- assert(String(t2AssistantMsg?.text || '').includes(`Echo: ${t2ProbePrompt}`), 'T3 assistant echo file content mismatch');
606
- console.log(`[${new Date().toISOString()}] [T3] ok: ordered probe lifecycle observed (user+processing, in-progress, assistant+processing clear)`);
722
+ console.log(`\n[${new Date().toISOString()}] === T3: probe chat protocol (SSE lifecycle) ===`);
723
+ const t3Dbg = (msg) => console.log(`[T3.DBG ${new Date().toISOString()}] ${msg}`);
724
+ t3Dbg('step 1: creating chat SSE client');
725
+ chatSseClientId = `chat-proto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
726
+ chatSseClient = startSseClient(`${BASE}/sse?clientId=${encodeURIComponent(chatSseClientId)}`, (payload) => {
727
+ captureChatEvents(payload, CHAT_CARD_ID);
728
+ });
729
+ await new Promise((r) => setTimeout(r, 400));
730
+ t3Dbg(`step 1: chat SSE client ready (clientId=${chatSseClientId})`);
731
+
732
+ t3Dbg('step 2: subscribing chat SSE client');
733
+ const subRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/subscribe-sse`, { clientId: chatSseClientId });
734
+ t3Dbg(`step 2: subscribe returned status=${subRes.status}`);
735
+ assert(subRes.status === 200, `chat subscribe returned ${subRes.status}`);
736
+
737
+ t3Dbg('step 3: fetching pre-chat transcript');
738
+ const t2Before = await httpGet(`${BASE}/cards/${CHAT_CARD_ID}/chats?all-turns=true`);
739
+ t3Dbg(`step 3: pre-chat fetch returned status=${t2Before.status}`);
740
+ assert(t2Before.status === 200, `T3 pre chats returned ${t2Before.status}`);
741
+ const t2BeforeMessages = Array.isArray(t2Before.data?.messages) ? t2Before.data.messages : [];
742
+ const t2BeforeCount = t2BeforeMessages.length;
743
+ const t2EventStart = NS.chatEvents.length;
744
+ const t2ProbePrompt = `Probe protocol validation ${Date.now()}`;
745
+ t3Dbg(`step 3: beforeCount=${t2BeforeCount}, eventStart=${t2EventStart}`);
746
+
747
+ const t3TurnId = randomTurnId();
748
+ t3Dbg(`step 4: posting probe chat-send (turn-id=${t3TurnId})`);
749
+ const t2SendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
750
+ actionType: 'chat-send',
751
+ payload: {
752
+ text: `${ECHO_PROBE_MARKER}${t2ProbePrompt}${ECHO_PROBE_MARKER}`,
753
+ 'turn-id': t3TurnId,
754
+ },
755
+ });
756
+ t3Dbg(`step 4: chat-send returned status=${t2SendRes.status}`);
757
+ assert(t2SendRes.status === 200, `T3 chat-send returned ${t2SendRes.status}`);
758
+
759
+ t3Dbg('step 5: waiting for ordered probe lifecycle on chat SSE');
760
+ const t2Lifecycle = await waitForChatPredicate((events) => {
761
+ return matchOrderedProbeLifecycle(events.slice(t2EventStart), {
762
+ beforeCount: t2BeforeCount,
763
+ beforeProcessing: false,
764
+ prompt: t2ProbePrompt,
765
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
766
+ });
767
+ }, 45_000, 'T3 ordered lifecycle');
768
+ t3Dbg('step 5: ordered lifecycle observed');
769
+ assert(!!t2Lifecycle, 'T3 ordered lifecycle not observed');
770
+
771
+ t3Dbg('step 6: fetching post-chat transcript');
772
+ const t2After = await httpGet(`${BASE}/cards/${CHAT_CARD_ID}/chats?all-turns=true`);
773
+ t3Dbg(`step 6: post-chat fetch returned status=${t2After.status}`);
774
+ assert(t2After.status === 200, `T3 post chats returned ${t2After.status}`);
775
+ const t2AfterMessages = Array.isArray(t2After.data?.messages) ? t2After.data.messages : [];
776
+ const t2NewMessages = t2AfterMessages.slice(t2BeforeCount);
777
+ t3Dbg(`step 6: validating ${t2NewMessages.length} new messages`);
778
+ assert(t2NewMessages.length >= 3, `T3 expected at least 3 new chat messages, got ${t2NewMessages.length}`);
779
+ const t3McpAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3TurnId });
780
+ const t3McpAfterData = expectMcpSuccess(t3McpAfter, 'T3 MCP post chats');
781
+ const t3TurnMessages = Array.isArray(t3McpAfterData?.messages) ? t3McpAfterData.messages : [];
782
+ t3Dbg(`step 6: MCP turn messages count=${t3TurnMessages.length}`);
783
+ assert(t3TurnMessages.length >= 3, `T3 expected at least 3 MCP messages for turn ${t3TurnId}, got ${t3TurnMessages.length}`);
784
+ for (const msg of t3TurnMessages) {
785
+ assert(String(msg?.turn || '') === t3TurnId, 'T3 MCP turn id mismatch');
786
+ }
787
+ const toComparableTurnMessage = (msg) => ({
788
+ id: String(msg?.id || ''),
789
+ role: String(msg?.role || ''),
790
+ text: String(msg?.text || ''),
791
+ });
792
+ const t3HttpComparable = t2NewMessages.map(toComparableTurnMessage);
793
+ const t3McpComparable = t3TurnMessages.map(toComparableTurnMessage);
794
+ assert(JSON.stringify(t3HttpComparable) === JSON.stringify(t3McpComparable),
795
+ `T3 HTTP /chats messages mismatch vs inspect.chat-messages-on-cards: http=${JSON.stringify(t3HttpComparable)} mcp=${JSON.stringify(t3McpComparable)}`);
796
+ const t2User = t2NewMessages.find((m) => m?.role === 'user');
797
+ const t2InProgress = t2NewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
798
+ const t2AssistantMsg = t2NewMessages.find((m) => m?.role === 'assistant');
799
+ assert(!!t2User && typeof t2User.id === 'string', 'T3 user chat message missing id');
800
+ assert(String(t2User?.text || '').includes(t2ProbePrompt), 'T3 user file text mismatch');
801
+ assert(!!t2InProgress && typeof t2InProgress.id === 'string', 'T3 in-progress system message missing id');
802
+ assert(!!t2AssistantMsg && typeof t2AssistantMsg.id === 'string', 'T3 assistant chat message missing id');
803
+ assert(String(t2AssistantMsg?.text || '').includes(`Echo: ${t2ProbePrompt}`), 'T3 assistant echo file content mismatch');
804
+ t3Dbg('step 6: all assertions passed');
805
+ console.log(`[${new Date().toISOString()}] [T3] ok: ordered probe lifecycle observed (user+processing, in-progress, assistant+processing clear)`);
607
806
  }
608
807
 
609
808
  // ── T3a: non-probe chat protocol over API + SSE ──
@@ -613,12 +812,18 @@ try {
613
812
  console.log('\n=== T3a: skipped (--skip-t3a) ===');
614
813
  } else {
615
814
  console.log('\n=== T3a: non-probe chat protocol (expect paris) ===');
815
+ const t3aDbg = (msg) => console.log(`[T3a.DBG ${new Date().toISOString()}] ${msg}`);
816
+ t3aDbg('step 1: fetching pre-chat transcript');
616
817
  const t2aBefore = await httpGet(`${BASE}/cards/${CHAT_CARD_ID}/chats?all-turns=true`);
818
+ t3aDbg(`step 1: pre-chat fetch returned status=${t2aBefore.status}`);
617
819
  assert(t2aBefore.status === 200, `T3a pre chats returned ${t2aBefore.status}`);
618
820
  const t2aBeforeMessages = Array.isArray(t2aBefore.data?.messages) ? t2aBefore.data.messages : [];
619
821
  const t2aBeforeCount = t2aBeforeMessages.length;
620
822
  const t2aPrompt = 'Just answer what is the capital of France. No Fluff. No COmmentary. No Markup Respond in lower case in one word.';
823
+ t3aDbg(`step 1: beforeCount=${t2aBeforeCount}`);
621
824
 
825
+ const t3aTurnId = randomTurnId();
826
+ t3aDbg(`step 2: posting non-probe chat-send (turn-id=${t3aTurnId})`);
622
827
  const t2aSendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
623
828
  actionType: 'chat-send',
624
829
  payload: {
@@ -626,10 +831,13 @@ try {
626
831
  prompt: t2aPrompt,
627
832
  chatTimeoutMs: 180000,
628
833
  }),
834
+ 'turn-id': t3aTurnId,
629
835
  },
630
836
  });
837
+ t3aDbg(`step 2: chat-send returned status=${t2aSendRes.status}`);
631
838
  assert(t2aSendRes.status === 200, `T3a chat-send returned ${t2aSendRes.status}`);
632
839
 
840
+ t3aDbg('step 3: waiting for assistant message containing paris on chat SSE');
633
841
  const t2aAssistant = await waitForChatPredicate((events) => {
634
842
  for (let i = events.length - 1; i >= 0; i -= 1) {
635
843
  const e = events[i];
@@ -639,16 +847,23 @@ try {
639
847
  }
640
848
  return false;
641
849
  }, 240_000, 'T3a assistant response with paris');
850
+ t3aDbg('step 3: assistant SSE event observed');
642
851
  assert(!!t2aAssistant, 'T3a assistant response with paris not observed on SSE');
852
+ const t2aSseLast = t2aAssistant.messages[t2aAssistant.messages.length - 1];
853
+ t3aDbg(`step 3: assistant SSE text=${JSON.stringify(String(t2aSseLast?.text || '').slice(0, 400))}`);
643
854
 
855
+ t3aDbg('step 4: fetching post-chat transcript');
644
856
  const t2aAfter = await httpGet(`${BASE}/cards/${CHAT_CARD_ID}/chats?all-turns=true`);
857
+ t3aDbg(`step 4: post-chat fetch returned status=${t2aAfter.status}`);
645
858
  assert(t2aAfter.status === 200, `T3a post chats returned ${t2aAfter.status}`);
646
859
  const t2aAfterMessages = Array.isArray(t2aAfter.data?.messages) ? t2aAfter.data.messages : [];
647
860
  const t2aNewMessages = t2aAfterMessages.slice(t2aBeforeCount);
861
+ t3aDbg(`step 4: validating ${t2aNewMessages.length} new messages`);
648
862
  assert(t2aNewMessages.length >= 2, `T3a expected at least 2 new chat messages, got ${t2aNewMessages.length}`);
649
863
  const t2aAssistantMsg = [...t2aNewMessages].reverse().find((m) => m?.role === 'assistant');
650
864
  assert(!!t2aAssistantMsg && typeof t2aAssistantMsg.id === 'string', 'T3a assistant chat message missing id');
651
865
  assert(/paris/i.test(String(t2aAssistantMsg?.text || '')), 'T3a assistant file content missing paris');
866
+ t3aDbg('step 4: all assertions passed');
652
867
  console.log('[T3a] ok: non-probe response contains paris');
653
868
  }
654
869
 
@@ -662,8 +877,9 @@ try {
662
877
  const t2bBeforeMessages = Array.isArray(t2bBefore.data?.messages) ? t2bBefore.data.messages : [];
663
878
  const t2bBeforeCount = t2bBeforeMessages.length;
664
879
 
880
+ const t3bTurnId = randomTurnId();
665
881
  const t2bUploadRes = await httpUploadChatFile(
666
- `${BASE}/cards/${CHAT_CARD_ID}/files?inChat=true`,
882
+ `${BASE}/cards/${CHAT_CARD_ID}/files?inChat=true&turn-id=${encodeURIComponent(t3bTurnId)}`,
667
883
  'q1.txt',
668
884
  'tokyo',
669
885
  );
@@ -702,6 +918,7 @@ try {
702
918
  payload: {
703
919
  text: `${ECHO_PROBE_MARKER}${t2bPrompt}${ECHO_PROBE_MARKER}`,
704
920
  files: [uploadedFile],
921
+ 'turn-id': t3bTurnId,
705
922
  },
706
923
  });
707
924
  assert(t2bSendRes.status === 200, `T3b chat-send returned ${t2bSendRes.status}`);
@@ -734,6 +951,601 @@ try {
734
951
  assert(String(t2bAssistantMsg?.text || '').trim() === 'tokyo', 'T3b assistant attachment content mismatch');
735
952
  console.log('[T3b] ok: upload protocol and ordered probe lifecycle observed with attachment-derived assistant reply');
736
953
  }
954
+
955
+ if (skipT4) {
956
+ console.log('\n=== T4: skipped (--skip-t4) ===');
957
+ } else {
958
+ console.log('\n=== T4: preflight MCP smoke checks ===');
959
+
960
+ const discoverSourceKindsData = expectMcpSuccess(
961
+ await httpMcp('discover.source-kinds', {}),
962
+ 'T4 discover.source-kinds',
963
+ );
964
+ assert(discoverSourceKindsData && typeof discoverSourceKindsData === 'object', 'T4 discover.source-kinds missing payload');
965
+ assert(discoverSourceKindsData.sourceKinds && typeof discoverSourceKindsData.sourceKinds === 'object', 'T4 discover.source-kinds missing sourceKinds');
966
+ const discoveredSourceKinds = Object.keys(discoverSourceKindsData.sourceKinds).sort();
967
+ assert(
968
+ JSON.stringify(discoveredSourceKinds) === JSON.stringify(['mock', 'sqlite', 'urls']),
969
+ `T4 discover.source-kinds mismatch: ${JSON.stringify(discoveredSourceKinds)}`,
970
+ );
971
+ console.log('[T4.discover] ok: source kinds match demo task executor');
972
+
973
+ const getCardDefinition = (fileName) => {
974
+ const filePath = path.join(BOARD_DIR, 'cards', fileName);
975
+ const raw = fs.readFileSync(filePath, 'utf-8');
976
+ return JSON.parse(raw);
977
+ };
978
+
979
+ const expectPreflightSuccess = (res, label) => {
980
+ assert(res.status === 200, `${label} returned ${res.status}`);
981
+ assert(res.data?.status === 'success', `${label} expected status=success, got ${JSON.stringify(res.data)}`);
982
+ assert(res.data?.data && typeof res.data.data === 'object', `${label} missing success data`);
983
+ return res.data.data;
984
+ };
985
+
986
+ const portfolioCard = getCardDefinition('cardT-portfolio.json');
987
+ const marketCard = getCardDefinition('cardT-market-prices.json');
988
+ const portfolioValueCard = getCardDefinition('cardT-portfolio-value.json');
989
+ const baseHoldings = Array.isArray(portfolioCard?.card_data?.holdings) ? deepCloneJson(portfolioCard.card_data.holdings) : [];
990
+
991
+ const mockQuotes = {
992
+ quoteResponse: {
993
+ result: [
994
+ { symbol: 'AAPL', shortName: 'Apple Inc.', regularMarketPrice: 198.15, regularMarketChange: 2.15, regularMarketChangePercent: 1.10 },
995
+ { symbol: 'MSFT', shortName: 'Microsoft Corp.', regularMarketPrice: 415.32, regularMarketChange: -1.23, regularMarketChangePercent: -0.30 },
996
+ { symbol: 'GOOGL', shortName: 'Alphabet Inc.', regularMarketPrice: 174.89, regularMarketChange: 0.89, regularMarketChangePercent: 0.51 },
997
+ { symbol: 'TSLA', shortName: 'Tesla Inc.', regularMarketPrice: 247.12, regularMarketChange: 5.43, regularMarketChangePercent: 2.25 },
998
+ ],
999
+ error: null,
1000
+ },
1001
+ };
1002
+
1003
+ const makePortfolioVariant = (id, extraHolding) => {
1004
+ const card = deepCloneJson(portfolioCard);
1005
+ card.id = id;
1006
+ card.card_data.holdings = [...baseHoldings, extraHolding];
1007
+ return card;
1008
+ };
1009
+
1010
+ const makeMockSourceCard = ({ id, bindTo = 'quotes', secondBindTo = null, includeProjection = false, projectionExpr = '"ok"', missingMock = false }) => {
1011
+ const card = deepCloneJson(marketCard);
1012
+ card.id = id;
1013
+ card.requires = [];
1014
+ card.source_defs = [
1015
+ { bindTo, mock: missingMock ? 'missing-mock-key' : 'quotes' },
1016
+ ...(secondBindTo ? [{ bindTo: secondBindTo, mock: 'quotes' }] : []),
1017
+ ];
1018
+ if (includeProjection) {
1019
+ card.source_defs[0].projections = { passthrough: projectionExpr };
1020
+ } else {
1021
+ delete card.source_defs[0].projections;
1022
+ }
1023
+ delete card.source_defs[0].urls;
1024
+ if (card.source_defs[1]) delete card.source_defs[1].urls;
1025
+ return card;
1026
+ };
1027
+
1028
+ const portfolioVariantA = makePortfolioVariant('card-portfolio-preflight-a', { ticker: 'NVDA', quantity: 7, cost_basis: 121 });
1029
+ const portfolioVariantB = makePortfolioVariant('card-portfolio-preflight-b', { ticker: 'AMD', quantity: 9, cost_basis: 143 });
1030
+ const marketMockSourceCardA = makeMockSourceCard({ id: 'card-market-prices-preflight-source-a' });
1031
+ const marketMockSourceCardB = makeMockSourceCard({ id: 'card-market-prices-preflight-source-b', includeProjection: true });
1032
+ const marketMockSourceCardC = makeMockSourceCard({ id: 'card-market-prices-preflight-source-c', secondBindTo: 'quotesBackup' });
1033
+ const marketMockSourceCardD = makeMockSourceCard({ id: 'card-market-prices-preflight-source-d', bindTo: 'quotesPrimary' });
1034
+ const marketMockSourceCardE = makeMockSourceCard({ id: 'card-market-prices-preflight-source-e', bindTo: 'quotesEcho' });
1035
+ const marketMissingMockCard = makeMockSourceCard({ id: 'card-market-prices-preflight-missing', missingMock: true });
1036
+ const invalidCard = {
1037
+ ...deepCloneJson(marketCard),
1038
+ id: '',
1039
+ source_defs: [{ bindTo: '', mock: 'quotes' }],
1040
+ view: { layout: { kind: 'stack' }, elements: [{ id: 'broken' }] },
1041
+ };
1042
+
1043
+ const validateSuccessCases = [
1044
+ { name: 'portfolio live', card: portfolioCard, expectCardId: 'card-portfolio' },
1045
+ { name: 'market live', card: marketCard, expectCardId: 'card-market-prices' },
1046
+ { name: 'portfolio-value live', card: portfolioValueCard, expectCardId: 'card-portfolio-value' },
1047
+ { name: 'portfolio variant', card: portfolioVariantA, expectCardId: 'card-portfolio-preflight-a' },
1048
+ { name: 'portfolio variant B', card: portfolioVariantB, expectCardId: 'card-portfolio-preflight-b' },
1049
+ ];
1050
+ for (const tc of validateSuccessCases) {
1051
+ const body = expectPreflightSuccess(await httpMcp('preflight.validate-candidate-card-definition', {
1052
+ candidate_card_content: tc.card,
1053
+ }), `T4 validate success (${tc.name})`);
1054
+ assert(body.cardId === tc.expectCardId, `T4 validate ${tc.name} cardId mismatch`);
1055
+ assert(body.isValid === true, `T4 validate ${tc.name} expected isValid=true`);
1056
+ assert(Array.isArray(body.issues) && body.issues.length === 0, `T4 validate ${tc.name} expected no issues`);
1057
+ console.log(`[T4.validate] ok: ${tc.name}`);
1058
+ }
1059
+
1060
+ const validateFailureBody = expectPreflightSuccess(await httpMcp('preflight.validate-candidate-card-definition', {
1061
+ candidate_card_content: invalidCard,
1062
+ }), 'T4 validate failure (invalid card)');
1063
+ assert(validateFailureBody.isValid === false, 'T4 validate invalid card should be invalid');
1064
+ assert(Array.isArray(validateFailureBody.issues) && validateFailureBody.issues.length > 0, 'T4 validate invalid card should report issues');
1065
+ console.log('[T4.validate] ok: invalid card reports validation issues');
1066
+
1067
+ const materializeSuccessCases = [
1068
+ {
1069
+ name: 'portfolio live empty mocks',
1070
+ card: portfolioCard,
1071
+ mockRequires: {},
1072
+ mockFetchedSources: {},
1073
+ verify: (body) => {
1074
+ assert(Array.isArray(body.provides_outputs?.holdings), 'T4 materialize portfolio holdings missing');
1075
+ assert(body.provides_outputs.holdings.length === baseHoldings.length, 'T4 materialize portfolio holdings length mismatch');
1076
+ assert(body.rendered_view?.elements?.[0]?.kind === 'editable-table', 'T4 materialize portfolio rendered_view mismatch');
1077
+ },
1078
+ },
1079
+ {
1080
+ name: 'portfolio variant with extra holding',
1081
+ card: portfolioVariantA,
1082
+ mockRequires: {},
1083
+ mockFetchedSources: {},
1084
+ verify: (body) => {
1085
+ assert(Array.isArray(body.provides_outputs?.holdings), 'T4 materialize portfolio variant holdings missing');
1086
+ assert(body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 materialize portfolio variant holdings length mismatch');
1087
+ },
1088
+ },
1089
+ {
1090
+ name: 'portfolio variant B with extra holding',
1091
+ card: portfolioVariantB,
1092
+ mockRequires: {},
1093
+ mockFetchedSources: {},
1094
+ verify: (body) => {
1095
+ assert(Array.isArray(body.provides_outputs?.holdings), 'T4 materialize portfolio variant B holdings missing');
1096
+ assert(body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 materialize portfolio variant B holdings length mismatch');
1097
+ },
1098
+ },
1099
+ {
1100
+ name: 'portfolio-value live with mock requires',
1101
+ card: portfolioValueCard,
1102
+ mockRequires: { holdings: baseHoldings, quotes: mockQuotes },
1103
+ mockFetchedSources: {},
1104
+ verify: (body) => {
1105
+ assert(Array.isArray(body.computed_values?.positions) && body.computed_values.positions.length > 0, 'T4 materialize portfolio-value positions missing');
1106
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length > 0, 'T4 materialize portfolio-value provides missing');
1107
+ assert(body.rendered_view?.elements?.length === 3, 'T4 materialize portfolio-value rendered_view length mismatch');
1108
+ },
1109
+ },
1110
+ {
1111
+ name: 'portfolio-value subset requires',
1112
+ card: portfolioValueCard,
1113
+ mockRequires: {
1114
+ holdings: baseHoldings.slice(0, 2),
1115
+ quotes: { quoteResponse: { result: mockQuotes.quoteResponse.result.slice(0, 2), error: null } },
1116
+ },
1117
+ mockFetchedSources: {},
1118
+ verify: (body) => {
1119
+ assert(Array.isArray(body.computed_values?.positions) && body.computed_values.positions.length === 2, 'T4 materialize portfolio-value subset positions mismatch');
1120
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length === 2, 'T4 materialize portfolio-value subset provides mismatch');
1121
+ },
1122
+ },
1123
+ ];
1124
+ for (const tc of materializeSuccessCases) {
1125
+ const body = expectPreflightSuccess(await httpMcp('preflight.materialize-candidate-card', {
1126
+ candidate_card_content: tc.card,
1127
+ mock_requires: tc.mockRequires,
1128
+ mock_fetched_sources: tc.mockFetchedSources,
1129
+ }), `T4 materialize success (${tc.name})`);
1130
+ assert(body.ok === true, `T4 materialize ${tc.name} expected ok=true`);
1131
+ assert(Array.isArray(body.errors) && body.errors.length === 0, `T4 materialize ${tc.name} expected no errors`);
1132
+ tc.verify(body);
1133
+ console.log(`[T4.materialize] ok: ${tc.name}`);
1134
+ }
1135
+
1136
+ const materializeFailureRes = await httpMcp('preflight.materialize-candidate-card', {
1137
+ candidate_card_content: portfolioCard,
1138
+ });
1139
+ assert(materializeFailureRes.status === 400, `T4 materialize missing args expected 400, got ${materializeFailureRes.status}`);
1140
+ assert(materializeFailureRes.data?.error === 'MCP tool requires mock_requires', 'T4 materialize missing args error mismatch');
1141
+ console.log('[T4.materialize] ok: missing required mocks is rejected');
1142
+
1143
+ const probeSuccessCases = [
1144
+ { name: 'single mock source base', card: marketMockSourceCardA, sourceIdx: 0, bindTo: 'quotes', mockProjections: {} },
1145
+ { name: 'two mock sources first entry', card: marketMockSourceCardC, sourceIdx: 0, bindTo: 'quotes', mockProjections: {} },
1146
+ { name: 'two mock sources second entry', card: marketMockSourceCardC, sourceIdx: 1, bindTo: 'quotesBackup', mockProjections: {} },
1147
+ { name: 'single mock source alternate bindTo', card: marketMockSourceCardD, sourceIdx: 0, bindTo: 'quotesPrimary', mockProjections: {} },
1148
+ ];
1149
+ for (const tc of probeSuccessCases) {
1150
+ const body = expectPreflightSuccess(await httpMcp('preflight.probe-single-source-in-candidate-card', {
1151
+ candidate_card_content: tc.card,
1152
+ source_idx: tc.sourceIdx,
1153
+ mock_projections: tc.mockProjections,
1154
+ }), `T4 probe success (${tc.name})`);
1155
+ assert(body.bindTo === tc.bindTo, `T4 probe ${tc.name} bindTo mismatch`);
1156
+ assert(body.reachable === true, `T4 probe ${tc.name} expected reachable=true`);
1157
+ assert(typeof body.latencyMs === 'number', `T4 probe ${tc.name} expected numeric latencyMs`);
1158
+ console.log(`[T4.probe] ok: ${tc.name}`);
1159
+ }
1160
+
1161
+ const probeFailureRes = await httpMcp('preflight.probe-single-source-in-candidate-card', {
1162
+ candidate_card_content: marketMissingMockCard,
1163
+ source_idx: 0,
1164
+ mock_projections: {},
1165
+ });
1166
+ assert(probeFailureRes.status === 400, `T4 probe failure expected 400, got ${probeFailureRes.status}`);
1167
+ assert(typeof probeFailureRes.data?.error === 'string' && probeFailureRes.data.error.length > 0, 'T4 probe failure expected error text');
1168
+ console.log('[T4.probe] ok: missing mock source returns HTTP error');
1169
+
1170
+ const runSourceSuccessCases = [
1171
+ { name: 'single mock source base', card: marketMockSourceCardA, sourceIdx: 0, bindTo: 'quotes' },
1172
+ { name: 'two mock sources first entry', card: marketMockSourceCardC, sourceIdx: 0, bindTo: 'quotes' },
1173
+ { name: 'two mock sources second entry', card: marketMockSourceCardC, sourceIdx: 1, bindTo: 'quotesBackup' },
1174
+ { name: 'single mock source alternate bindTo', card: marketMockSourceCardE, sourceIdx: 0, bindTo: 'quotesEcho' },
1175
+ ];
1176
+ for (const tc of runSourceSuccessCases) {
1177
+ const body = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-candidate-card', {
1178
+ candidate_card_content: tc.card,
1179
+ source_idx: tc.sourceIdx,
1180
+ mock_projections: {},
1181
+ }), `T4 run-source success (${tc.name})`);
1182
+ assert(body.bindTo === tc.bindTo, `T4 run-source ${tc.name} bindTo mismatch`);
1183
+ assert(body.ok === true, `T4 run-source ${tc.name} expected ok=true`);
1184
+ assert(Array.isArray(body.issues) && body.issues.length === 0, `T4 run-source ${tc.name} expected no issues`);
1185
+ assert(Array.isArray(body.result?.quoteResponse?.result) && body.result.quoteResponse.result.length > 0, `T4 run-source ${tc.name} result shape mismatch`);
1186
+ console.log(`[T4.run-source] ok: ${tc.name}`);
1187
+ }
1188
+
1189
+ const runSourceFailureBody = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-candidate-card', {
1190
+ candidate_card_content: marketMissingMockCard,
1191
+ source_idx: 0,
1192
+ mock_projections: {},
1193
+ }), 'T4 run-source failure (missing mock source)');
1194
+ assert(runSourceFailureBody.ok === false, 'T4 run-source missing mock should set ok=false');
1195
+ assert(Array.isArray(runSourceFailureBody.issues) && runSourceFailureBody.issues.length > 0, 'T4 run-source missing mock should report issues');
1196
+ console.log('[T4.run-source] ok: missing mock source returns ok=false with issues');
1197
+
1198
+ const liveRunCardId = String(marketCard?.id || 'card-market-prices');
1199
+
1200
+ const liveRunSourceBody = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-live-card', {
1201
+ card_id: liveRunCardId,
1202
+ source_idx: 0,
1203
+ mock_requires: { holdings: baseHoldings },
1204
+ }), 'T4 run-source live card success');
1205
+ assert(liveRunSourceBody.bindTo === 'quotes', 'T4 run-source live card bindTo mismatch');
1206
+ assert(liveRunSourceBody.ok === true, 'T4 run-source live card expected ok=true');
1207
+ assert(Array.isArray(liveRunSourceBody.issues) && liveRunSourceBody.issues.length === 0, 'T4 run-source live card expected no issues');
1208
+ assert(Array.isArray(liveRunSourceBody.result) && liveRunSourceBody.result.length === baseHoldings.length, 'T4 run-source live card result shape mismatch');
1209
+ console.log('[T4.run-source-live] ok: live card source run returns candidate-compatible shape');
1210
+
1211
+ const liveRunRequiresBody = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-live-card', {
1212
+ card_id: liveRunCardId,
1213
+ source_idx: 0,
1214
+ mock_requires: { holdings: baseHoldings },
1215
+ }), 'T4 run-source live card uses mock_requires in projections');
1216
+ assert(liveRunRequiresBody.bindTo === 'quotes', 'T4 run-source live card requires bindTo mismatch');
1217
+ assert(liveRunRequiresBody.ok === true, 'T4 run-source live card requires expected ok=true');
1218
+ assert(Array.isArray(liveRunRequiresBody.issues) && liveRunRequiresBody.issues.length === 0, 'T4 run-source live card requires expected no issues');
1219
+ assert(Array.isArray(liveRunRequiresBody.result) && liveRunRequiresBody.result.length === baseHoldings.length, 'T4 run-source live card requires result shape mismatch');
1220
+ console.log('[T4.run-source-live] ok: non-empty mock_requires is consumed via source projections');
1221
+
1222
+ const liveRunOutOfRangeRes = await httpMcp('preflight.run-single-source-in-live-card', {
1223
+ card_id: liveRunCardId,
1224
+ source_idx: 9,
1225
+ mock_requires: {},
1226
+ });
1227
+ assert(liveRunOutOfRangeRes.status === 400, `T4 run-source live card out-of-range expected 400, got ${liveRunOutOfRangeRes.status}`);
1228
+ assert(typeof liveRunOutOfRangeRes.data?.error === 'string' && liveRunOutOfRangeRes.data.error.length > 0, 'T4 run-source live card out-of-range expected error text');
1229
+ console.log('[T4.run-source-live] ok: out-of-range source_idx is rejected with HTTP error');
1230
+
1231
+ const liveRunMissingMockRequiresRes = await httpMcp('preflight.run-single-source-in-live-card', {
1232
+ card_id: liveRunCardId,
1233
+ source_idx: 0,
1234
+ });
1235
+ assert(liveRunMissingMockRequiresRes.status === 400, `T4 run-source live card missing mock_requires expected 400, got ${liveRunMissingMockRequiresRes.status}`);
1236
+ assert(liveRunMissingMockRequiresRes.data?.error === 'MCP tool requires mock_requires', 'T4 run-source live card missing mock_requires error mismatch');
1237
+ console.log('[T4.run-source-live] ok: missing mock_requires is rejected');
1238
+
1239
+ const runCycleSuccessCases = [
1240
+ {
1241
+ name: 'portfolio live',
1242
+ card: portfolioCard,
1243
+ mockRequires: {},
1244
+ verify: (body) => {
1245
+ assert(Array.isArray(body.provides_outputs?.holdings) && body.provides_outputs.holdings.length === baseHoldings.length, 'T4 run-cycle portfolio provides mismatch');
1246
+ assert(body.rendered_view?.elements?.[0]?.kind === 'editable-table', 'T4 run-cycle portfolio rendered_view mismatch');
1247
+ },
1248
+ },
1249
+ {
1250
+ name: 'portfolio variant',
1251
+ card: portfolioVariantB,
1252
+ mockRequires: {},
1253
+ verify: (body) => {
1254
+ assert(Array.isArray(body.provides_outputs?.holdings) && body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 run-cycle portfolio variant provides mismatch');
1255
+ },
1256
+ },
1257
+ {
1258
+ name: 'portfolio variant B',
1259
+ card: portfolioVariantB,
1260
+ mockRequires: {},
1261
+ verify: (body) => {
1262
+ assert(Array.isArray(body.provides_outputs?.holdings) && body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 run-cycle portfolio variant B provides mismatch');
1263
+ assert(body.rendered_view?.elements?.[0]?.kind === 'editable-table', 'T4 run-cycle portfolio variant B rendered_view mismatch');
1264
+ },
1265
+ },
1266
+ {
1267
+ name: 'portfolio-value with full requires',
1268
+ card: portfolioValueCard,
1269
+ mockRequires: { holdings: baseHoldings, quotes: mockQuotes },
1270
+ verify: (body) => {
1271
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length > 0, 'T4 run-cycle portfolio-value provides mismatch');
1272
+ assert(body.rendered_view?.elements?.length === 3, 'T4 run-cycle portfolio-value rendered_view mismatch');
1273
+ },
1274
+ },
1275
+ {
1276
+ name: 'portfolio-value subset requires',
1277
+ card: portfolioValueCard,
1278
+ mockRequires: {
1279
+ holdings: baseHoldings.slice(0, 2),
1280
+ quotes: { quoteResponse: { result: mockQuotes.quoteResponse.result.slice(0, 2), error: null } },
1281
+ },
1282
+ verify: (body) => {
1283
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length === 2, 'T4 run-cycle portfolio-value subset length mismatch');
1284
+ },
1285
+ },
1286
+ {
1287
+ name: 'market-prices with live source simulation',
1288
+ card: marketCard,
1289
+ mockRequires: { holdings: baseHoldings.slice(0, 3) },
1290
+ verify: (body) => {
1291
+ const quoteRows = body.provides_outputs?.quotes?.quoteResponse?.result;
1292
+ assert(Array.isArray(quoteRows) && quoteRows.length === 3, 'T4 run-cycle market-prices provides result length mismatch');
1293
+ assert(typeof quoteRows[0]?.symbol === 'string' && quoteRows[0].symbol.length > 0, 'T4 run-cycle market-prices provides symbol missing');
1294
+
1295
+ const resolvedRows = body.rendered_view?.elements?.[0]?.resolved;
1296
+ assert(Array.isArray(resolvedRows) && resolvedRows.length === 3, 'T4 run-cycle market-prices rendered resolved length mismatch');
1297
+ assert(typeof resolvedRows[0]?.ticker === 'string' && resolvedRows[0].ticker.length > 0, 'T4 run-cycle market-prices rendered ticker missing');
1298
+ assert(typeof resolvedRows[0]?.price === 'number', 'T4 run-cycle market-prices rendered price missing');
1299
+ },
1300
+ },
1301
+ ];
1302
+ for (const tc of runCycleSuccessCases) {
1303
+ const body = expectPreflightSuccess(await httpMcp('preflight.run-one-cycle-with-candidate-card', {
1304
+ candidate_card_content: tc.card,
1305
+ mock_requires: tc.mockRequires,
1306
+ }), `T4 run-cycle success (${tc.name})`);
1307
+ assert(body.ok === true, `T4 run-cycle ${tc.name} expected ok=true`);
1308
+ assert(Array.isArray(body.issues) && body.issues.length === 0, `T4 run-cycle ${tc.name} expected no issues`);
1309
+ tc.verify(body);
1310
+ console.log(`[T4.run-cycle] ok: ${tc.name}`);
1311
+ }
1312
+
1313
+ console.log('\n[T4.remove-card] testing manage.remove-card lifecycle');
1314
+
1315
+ const T4_REMOVE_CARD_ID = 'card-t4-remove-test';
1316
+ const T4_REMOVE_CARD_V1 = {
1317
+ id: T4_REMOVE_CARD_ID,
1318
+ card_data: { label: 'v1', color: 'blue' },
1319
+ };
1320
+ const T4_REMOVE_CARD_V2 = {
1321
+ id: T4_REMOVE_CARD_ID,
1322
+ card_data: { label: 'v2', color: 'red' },
1323
+ };
1324
+
1325
+ const t4UpsertV1Res = await httpMcp('manage.upsert-card', {
1326
+ card_id: T4_REMOVE_CARD_ID,
1327
+ candidate_card_content: T4_REMOVE_CARD_V1,
1328
+ });
1329
+ assert(t4UpsertV1Res.status === 200, `T4.remove-card v1 upsert returned ${t4UpsertV1Res.status}`);
1330
+ const t4UpsertV1Data = expectMcpSuccess(t4UpsertV1Res, 'T4.remove-card v1 upsert');
1331
+ assert(t4UpsertV1Data?.board_result?.status === 'success', 'T4.remove-card v1 upsert board_result expected success');
1332
+ console.log('[T4.remove-card] ok: v1 card upserted');
1333
+
1334
+ let t4StatusBeforeRemove = null;
1335
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1336
+ t4StatusBeforeRemove = expectMcpSuccess(
1337
+ await httpMcp('inspect.board-runtime-status', {}),
1338
+ 'T4.remove-card board-runtime-status before remove',
1339
+ );
1340
+ const cards = Array.isArray(t4StatusBeforeRemove?.cards) ? t4StatusBeforeRemove.cards : [];
1341
+ if (cards.some(c => c['card-id'] === T4_REMOVE_CARD_ID)) break;
1342
+ if (attempt < 2) {
1343
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
1344
+ }
1345
+ }
1346
+ const t4CardsBefore = Array.isArray(t4StatusBeforeRemove?.cards) ? t4StatusBeforeRemove.cards : [];
1347
+ assert(t4CardsBefore.some(c => c['card-id'] === T4_REMOVE_CARD_ID), 'T4.remove-card: card not found in board-runtime-status before remove');
1348
+ const t4CardCountBefore = t4StatusBeforeRemove?.summary?.card_count ?? 0;
1349
+ console.log(`[T4.remove-card] ok: board-runtime-status has ${t4CardCountBefore} cards before remove (includes ${T4_REMOVE_CARD_ID})`);
1350
+
1351
+ const t4BoardEventsBefore = NS.boardEvents.length;
1352
+ const t4RemoveRes = await httpMcp('manage.remove-card', { card_id: T4_REMOVE_CARD_ID });
1353
+ assert(t4RemoveRes.status === 200, `T4.remove-card remove returned ${t4RemoveRes.status}`);
1354
+ const t4RemoveData = expectMcpSuccess(t4RemoveRes, 'T4.remove-card remove');
1355
+ assert(t4RemoveData?.board_result?.status === 'success', 'T4.remove-card board_result expected success');
1356
+ assert(t4RemoveData?.store_result?.status === 'success', 'T4.remove-card store_result expected success');
1357
+ console.log('[T4.remove-card] ok: manage.remove-card returned success for both board and store');
1358
+
1359
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
1360
+
1361
+ const t4CardRemovedEvent = await waitUntil(
1362
+ () => NS.boardEvents.slice(t4BoardEventsBefore).find(e => e.kind === 'card_removed' && e.cardId === T4_REMOVE_CARD_ID) || false,
1363
+ 10_000,
1364
+ `card_removed SSE notification for ${T4_REMOVE_CARD_ID}`,
1365
+ );
1366
+ assert(t4CardRemovedEvent && t4CardRemovedEvent.kind === 'card_removed', 'T4.remove-card: card_removed SSE event not received');
1367
+ assert(t4CardRemovedEvent.cardId === T4_REMOVE_CARD_ID, 'T4.remove-card: card_removed SSE cardId mismatch');
1368
+ console.log(`[T4.remove-card] ok: card_removed SSE notification received for ${T4_REMOVE_CARD_ID}`);
1369
+
1370
+ const t4StatusAfterRemove = expectMcpSuccess(
1371
+ await httpMcp('inspect.board-runtime-status', {}),
1372
+ 'T4.remove-card board-runtime-status after remove',
1373
+ );
1374
+ const t4CardsAfter = Array.isArray(t4StatusAfterRemove?.cards) ? t4StatusAfterRemove.cards : [];
1375
+ assert(!t4CardsAfter.some(c => c['card-id'] === T4_REMOVE_CARD_ID), 'T4.remove-card: card still present in board-runtime-status after remove');
1376
+ const t4CardCountAfter = t4StatusAfterRemove?.summary?.card_count ?? 0;
1377
+ assert(t4CardCountAfter === t4CardCountBefore - 1, `T4.remove-card: card_count expected ${t4CardCountBefore - 1}, got ${t4CardCountAfter}`);
1378
+ console.log(`[T4.remove-card] ok: card absent from board-runtime-status after remove (count: ${t4CardCountBefore} → ${t4CardCountAfter})`);
1379
+
1380
+ const t4BoardEventsBeforeV2 = NS.boardEvents.length;
1381
+ const t4UpsertV2Res = await httpMcp('manage.upsert-card', {
1382
+ card_id: T4_REMOVE_CARD_ID,
1383
+ candidate_card_content: T4_REMOVE_CARD_V2,
1384
+ });
1385
+ assert(t4UpsertV2Res.status === 200, `T4.remove-card v2 upsert returned ${t4UpsertV2Res.status}`);
1386
+ const t4UpsertV2Data = expectMcpSuccess(t4UpsertV2Res, 'T4.remove-card v2 upsert');
1387
+ assert(t4UpsertV2Data?.board_result?.status === 'success', 'T4.remove-card v2 upsert board_result expected success');
1388
+ console.log('[T4.remove-card] ok: v2 card upserted under same id');
1389
+
1390
+ const t4CardRefreshedEvent = await waitUntil(
1391
+ () => NS.boardEvents.slice(t4BoardEventsBeforeV2).find(e => e.kind === 'card_refreshed' && e.cardId === T4_REMOVE_CARD_ID) || false,
1392
+ 10_000,
1393
+ `card_refreshed SSE notification for ${T4_REMOVE_CARD_ID} after v2 upsert`,
1394
+ );
1395
+ assert(t4CardRefreshedEvent && t4CardRefreshedEvent.kind === 'card_refreshed', 'T4.remove-card: card_refreshed SSE event not received after v2 upsert');
1396
+ console.log(`[T4.remove-card] ok: card_refreshed SSE notification received for v2 of ${T4_REMOVE_CARD_ID}`);
1397
+
1398
+ await waitForAllCompleted(30_000, 'T4 remove-card re-upsert completion');
1399
+
1400
+ const t4StatusAfterV2 = expectMcpSuccess(
1401
+ await httpMcp('inspect.board-runtime-status', {}),
1402
+ 'T4.remove-card board-runtime-status after v2 upsert',
1403
+ );
1404
+
1405
+ const t4CardsAfterV2 = Array.isArray(t4StatusAfterV2?.cards) ? t4StatusAfterV2.cards : [];
1406
+ assert(t4CardsAfterV2.some(c => c['card-id'] === T4_REMOVE_CARD_ID), 'T4.remove-card: v2 card missing from board-runtime-status');
1407
+ const t4CardCountAfterV2 = t4StatusAfterV2?.summary?.card_count ?? 0;
1408
+ assert(t4CardCountAfterV2 === t4CardCountBefore, `T4.remove-card: card_count after v2 upsert expected ${t4CardCountBefore}, got ${t4CardCountAfterV2}`);
1409
+ console.log('[T4.remove-card] ok: v2 card present in board-runtime-status');
1410
+
1411
+ const t4InspectV2Data = expectMcpSuccess(
1412
+ await httpMcp('inspect.card-definition-and-runtime', { card_id: T4_REMOVE_CARD_ID }),
1413
+ 'T4.remove-card inspect v2',
1414
+ );
1415
+ assert(t4InspectV2Data?.cardId === T4_REMOVE_CARD_ID, 'T4.remove-card inspect v2 cardId mismatch');
1416
+ const t4V2CardData = t4InspectV2Data?.card_definition_and_static_data?.card_data ?? null;
1417
+ assert(t4V2CardData?.label === 'v2', `T4.remove-card inspect v2 label expected "v2", got "${t4V2CardData?.label}"`);
1418
+ assert(t4V2CardData?.color === 'red', `T4.remove-card inspect v2 color expected "red", got "${t4V2CardData?.color}"`);
1419
+ console.log('[T4.remove-card] ok: inspect.card-definition-and-runtime reflects v2 card_data after re-upsert');
1420
+
1421
+ await httpMcp('manage.remove-card', { card_id: T4_REMOVE_CARD_ID });
1422
+ console.log('[T4.remove-card] cleanup done');
1423
+
1424
+ }
1425
+
1426
+ if (skipT5) {
1427
+ console.log('\n=== T5: skipped (--skip-t5) ===');
1428
+ } else {
1429
+ console.log('\n=== T5: mcp-controlplane setstate/getstate ===');
1430
+ const T5_CARD_ID = CHAT_CARD_ID;
1431
+
1432
+ const t5IsProcInit = expectMcpSuccess(
1433
+ await httpMcpControlplane('getstate.is-chat-processing', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
1434
+ 'T5 getstate.is-chat-processing initial',
1435
+ );
1436
+ assert(t5IsProcInit?.active === false, `T5 expected initial chat-processing=false, got ${JSON.stringify(t5IsProcInit)}`);
1437
+
1438
+ expectMcpSuccess(
1439
+ await httpMcpControlplane('setstate.chat-processing-started', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
1440
+ 'T5 setstate.chat-processing-started',
1441
+ );
1442
+ const t5IsProcStarted = expectMcpSuccess(
1443
+ await httpMcpControlplane('getstate.is-chat-processing', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
1444
+ 'T5 getstate.is-chat-processing after start',
1445
+ );
1446
+ assert(t5IsProcStarted?.active === true, 'T5 expected chat-processing=true after setstate.chat-processing-started');
1447
+
1448
+ expectMcpSuccess(
1449
+ await httpMcpControlplane('setstate.chat-processing-done', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
1450
+ 'T5 setstate.chat-processing-done',
1451
+ );
1452
+ const t5IsProcDone = expectMcpSuccess(
1453
+ await httpMcpControlplane('getstate.is-chat-processing', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
1454
+ 'T5 getstate.is-chat-processing after done',
1455
+ );
1456
+ assert(t5IsProcDone?.active === false, 'T5 expected chat-processing=false after setstate.chat-processing-done');
1457
+ console.log('[T5] ok: setstate/getstate chat-processing round-trip');
1458
+
1459
+ const t5ThreadId = `thread-t5-${Date.now()}`;
1460
+ expectMcpSuccess(
1461
+ await httpMcpControlplane('setstate.card-meta', { board_id: BOARD_ID, card_id: T5_CARD_ID, key: 'chat.foundry_thread_id', value: t5ThreadId }),
1462
+ 'T5 setstate.card-meta',
1463
+ );
1464
+ const t5GetMeta = expectMcpSuccess(
1465
+ await httpMcpControlplane('getstate.card-meta', { board_id: BOARD_ID, card_id: T5_CARD_ID, key: 'chat.foundry_thread_id' }),
1466
+ 'T5 getstate.card-meta',
1467
+ );
1468
+ assert(t5GetMeta?.exists === true && t5GetMeta?.value === t5ThreadId, `T5 getstate.card-meta mismatch: ${JSON.stringify(t5GetMeta)}`);
1469
+
1470
+ const t5ReadCards = expectMcpSuccess(
1471
+ await httpMcp('manage.read-card', { card_id: T5_CARD_ID }),
1472
+ 'T5 manage.read-card meta-redaction',
1473
+ );
1474
+ const t5ReadCard = Array.isArray(t5ReadCards) ? t5ReadCards[0] : null;
1475
+ assert(t5ReadCard && t5ReadCard.meta === undefined, 'T5 expected manage.read-card to redact top-level meta');
1476
+
1477
+ const t5Inspect = expectMcpSuccess(
1478
+ await httpMcp('inspect.card-definition-and-runtime', { card_id: T5_CARD_ID }),
1479
+ 'T5 inspect.card-definition-and-runtime meta-redaction',
1480
+ );
1481
+ assert(t5Inspect?.card_definition_and_static_data?.meta === undefined, 'T5 expected inspect to redact card_definition_and_static_data.meta');
1482
+ console.log('[T5] ok: regular /mcp surfaces redact card meta');
1483
+
1484
+ // ── T5: admin-only card round-trip ──────────────────────────────────────
1485
+ // 1. Read the existing card definition via the normal read-card path.
1486
+ const t5AdminCardId = T5_CARD_ID;
1487
+ const t5NormalRead = expectMcpSuccess(
1488
+ await httpMcp('manage.read-card', { card_id: t5AdminCardId }),
1489
+ 'T5 admin setup: manage.read-card before marking admin',
1490
+ );
1491
+ const t5OriginalCard = Array.isArray(t5NormalRead) ? t5NormalRead[0] : null;
1492
+ assert(t5OriginalCard, 'T5 expected card definition before marking as admin-only');
1493
+
1494
+ // 2. Upsert it as an admin-only card via the controlplane tool.
1495
+ const t5AdminUpsert = expectMcpSuccess(
1496
+ await httpMcpControlplane('manage.admin-upsert-card', {
1497
+ board_id: BOARD_ID,
1498
+ card_id: t5AdminCardId,
1499
+ candidate_card_content: t5OriginalCard,
1500
+ }),
1501
+ 'T5 manage.admin-upsert-card',
1502
+ );
1503
+ assert(t5AdminUpsert?.board_result, 'T5 expected board_result from admin upsert');
1504
+ console.log('[T5] ok: manage.admin-upsert-card succeeded');
1505
+
1506
+ // 3. Verify the card is now invisible on the regular /mcp surface.
1507
+ // Server throws with statusCode:404 for admin cards, so httpResult.status will be 404 (not 200).
1508
+ const t5HiddenRead = await httpMcp('manage.read-card', { card_id: t5AdminCardId });
1509
+ assert(t5HiddenRead?.status !== 200 || t5HiddenRead?.data?.status === 'fail',
1510
+ `T5 expected manage.read-card to be blocked after admin upsert, got: ${JSON.stringify(t5HiddenRead)}`);
1511
+ console.log('[T5] ok: manage.read-card blocked for admin-only card');
1512
+
1513
+ // 4. Verify the card IS visible via the controlplane admin-read-card tool.
1514
+ const t5AdminRead = expectMcpSuccess(
1515
+ await httpMcpControlplane('manage.admin-read-card', { board_id: BOARD_ID, card_id: t5AdminCardId }),
1516
+ 'T5 manage.admin-read-card',
1517
+ );
1518
+ const t5AdminCards = Array.isArray(t5AdminRead?.cards) ? t5AdminRead.cards : [];
1519
+ assert(t5AdminCards.length > 0, 'T5 expected admin-read-card to return the card');
1520
+ assert(t5AdminCards[0]?.meta?.__visible_controlplane_only === true, `T5 expected meta.__visible_controlplane_only=true, got: ${JSON.stringify(t5AdminCards[0]?.meta)}`);
1521
+ console.log('[T5] ok: manage.admin-read-card returns card with __visible_controlplane_only=true');
1522
+
1523
+ // 5. Guard: setstate.card-meta must block changing the flag to a different value.
1524
+ // key = 'chat.__visible_controlplane_only' passes the chat.* format check but contains
1525
+ // the reserved segment, so it reaches the guard.
1526
+ const t5MetaGuard = await httpMcpControlplane('setstate.card-meta', {
1527
+ board_id: BOARD_ID,
1528
+ card_id: t5AdminCardId,
1529
+ key: 'chat.__visible_controlplane_only',
1530
+ value: false, // differs from current flag value (true) → must be rejected
1531
+ });
1532
+ assert(t5MetaGuard?.status !== 200,
1533
+ `T5 expected setstate.card-meta to reject changing __visible_controlplane_only, got: ${JSON.stringify(t5MetaGuard)}`);
1534
+ console.log('[T5] ok: setstate.card-meta blocked flag mutation (false != true)');
1535
+
1536
+ // 6. Guard: same key with value matching the current flag (true) must pass (idempotent).
1537
+ const t5MetaIdempotent = expectMcpSuccess(
1538
+ await httpMcpControlplane('setstate.card-meta', {
1539
+ board_id: BOARD_ID,
1540
+ card_id: t5AdminCardId,
1541
+ key: 'chat.__visible_controlplane_only',
1542
+ value: true, // matches current flag value → idempotent, allowed
1543
+ }),
1544
+ 'T5 setstate.card-meta idempotent same-value',
1545
+ );
1546
+ assert(t5MetaIdempotent, 'T5 expected setstate.card-meta to succeed with matching flag value');
1547
+ console.log('[T5] ok: setstate.card-meta idempotent same-value allowed');
1548
+ }
737
1549
  }
738
1550
 
739
1551
  console.log('\n=== All smoke checks passed ===\n');
@@ -744,8 +1556,7 @@ try {
744
1556
  } catch { /* ignore */ }
745
1557
  }
746
1558
  if (chatSseClient) chatSseClient.close();
747
- serverProc.kill();
748
- await new Promise((r) => serverProc.on('exit', r));
1559
+ await stopChildProcess(serverProc, 'demo board server');
749
1560
  if (sseWorker) await sseWorker.terminate();
750
1561
 
751
1562
  // Clean up the test setup directory