diff --git a/services/control-plane/src/methods.ts b/services/control-plane/src/methods.ts index 8c7ec0bf..029cc446 100644 --- a/services/control-plane/src/methods.ts +++ b/services/control-plane/src/methods.ts @@ -197,11 +197,17 @@ function firstDevnetMap(state: LoadedControlPlaneState, keys: string[]): Record< } function devnetBlocksArray(state: LoadedControlPlaneState): JsonObject[] { - const candidate = asJsonArray(state.devnet?.blocks).length > 0 - ? asJsonArray(state.devnet?.blocks) - : asJsonArray(state.devnetControlPlaneHandoff?.blocks).length > 0 - ? asJsonArray(state.devnetControlPlaneHandoff?.blocks) - : asJsonArray(state.devnetIndexerHandoff?.blocks); + const sources = [ + asJsonArray(state.devnet?.blocks), + asJsonArray(state.devnetControlPlaneHandoff?.blocks), + asJsonArray(state.devnetIndexerHandoff?.blocks), + ]; + const candidate = sources.find((blocks) => + blocks.some((entry) => { + const block = asJsonObject(entry); + return block !== null && stringList(block.txIds).length > 0; + }), + ) ?? sources.find((blocks) => blocks.length > 0) ?? []; return candidate .map((entry) => asJsonObject(entry)) .filter((entry): entry is JsonObject => entry !== null); diff --git a/services/control-plane/src/smoke.ts b/services/control-plane/src/smoke.ts index 5e085334..ca82c922 100644 --- a/services/control-plane/src/smoke.ts +++ b/services/control-plane/src/smoke.ts @@ -5,8 +5,18 @@ import { loadControlPlaneState } from "./fixture-state.ts"; import type { ControlPlanePaths, JsonObject, RpcErrorResponse, RpcSuccessResponse } from "./types.ts"; function firstDevnetBlock(state: ReturnType): JsonObject { - const blocks = Array.isArray(state.devnet?.blocks) ? state.devnet.blocks : []; - const block = blocks[0]; + const blocksResponse = dispatchJsonRpc( + { jsonrpc: "2.0", id: "blocks-prefetch", method: "block_list", params: { limit: 10 } }, + { state }, + ) as RpcSuccessResponse; + const blocks = (blocksResponse.result as JsonObject).blocks; + const blockRows = Array.isArray(blocks) ? blocks : []; + const block = blockRows.find((entry) => { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + return Array.isArray((entry as JsonObject).txIds) && ((entry as JsonObject).txIds as unknown[]).length > 0; + }) ?? blockRows[0]; if (block === null || typeof block !== "object" || Array.isArray(block)) { throw new Error("control-plane smoke requires at least one local devnet block"); } diff --git a/services/control-plane/test/control-plane.test.ts b/services/control-plane/test/control-plane.test.ts index 09b3518e..050d0778 100644 --- a/services/control-plane/test/control-plane.test.ts +++ b/services/control-plane/test/control-plane.test.ts @@ -420,6 +420,35 @@ test("smoke client queries the complete local lifecycle surface", () => { rmSync(dir, { recursive: true, force: true }); }); +test("smoke client ignores stale local devnet blocks without transactions", () => { + const dir = mkdtempSync(join(tmpdir(), "flowmemory-control-plane-stale-devnet-")); + try { + const staleDevnetPath = join(dir, "state.json"); + writeFileSync(staleDevnetPath, JSON.stringify({ + schema: "flowmemory.local_devnet.state.v0", + blocks: [{ + schema: "flowmemory.local_devnet.block.v0", + blockNumber: "1", + blockHash: "0x1909a47bfaaabbfe51d371173d550fcdaff1abaedeea1045bfb77a496bdb8695", + txIds: [], + receipts: [], + }], + })); + + const smoke = runControlPlaneSmoke({ + localDevnetPath: staleDevnetPath, + txIntakePath: join(dir, "transactions.ndjson"), + bridgeObservationIntakePath: join(dir, "bridge-observations.ndjson"), + }); + + assert.equal(smoke.schema, "flowmemory.control_plane.smoke.v0"); + assert.equal(smoke.ok, true); + assert.equal(typeof (smoke.queried as Record).txId, "string"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("HTTP server exposes browser-safe health and state endpoints", async () => { const server = startControlPlaneServer({ host: "127.0.0.1", port: 0 });