@honua/mcp-server 0.1.2-beta.0 → 0.1.8-beta.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 (117) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +232 -9
  3. package/dist/src/certification/admin-parity.d.ts +96 -0
  4. package/dist/src/certification/admin-parity.d.ts.map +1 -0
  5. package/dist/src/certification/admin-parity.js +235 -0
  6. package/dist/src/certification/census-fixture-client.d.ts.map +1 -1
  7. package/dist/src/certification/census-fixture-client.js +5 -0
  8. package/dist/src/certification/certifier.d.ts +1 -1
  9. package/dist/src/certification/certifier.d.ts.map +1 -1
  10. package/dist/src/certification/certifier.js +28 -20
  11. package/dist/src/certification/cli.js +1 -1
  12. package/dist/src/certification/cql2.d.ts +24 -0
  13. package/dist/src/certification/cql2.d.ts.map +1 -0
  14. package/dist/src/certification/cql2.js +354 -0
  15. package/dist/src/certification/fixtures.d.ts +8 -0
  16. package/dist/src/certification/fixtures.d.ts.map +1 -1
  17. package/dist/src/certification/fixtures.js +26 -10
  18. package/dist/src/certification/ogc-data.d.ts +30 -0
  19. package/dist/src/certification/ogc-data.d.ts.map +1 -0
  20. package/dist/src/certification/ogc-data.js +726 -0
  21. package/dist/src/certification/ogc-fixture-client.d.ts +4 -0
  22. package/dist/src/certification/ogc-fixture-client.d.ts.map +1 -0
  23. package/dist/src/certification/ogc-fixture-client.js +190 -0
  24. package/dist/src/certification/target.d.ts +10 -1
  25. package/dist/src/certification/target.d.ts.map +1 -1
  26. package/dist/src/certification/target.js +63 -28
  27. package/dist/src/credential-endpoint.d.ts +3 -0
  28. package/dist/src/credential-endpoint.d.ts.map +1 -0
  29. package/dist/src/credential-endpoint.js +24 -0
  30. package/dist/src/eval/cli.js +6 -3
  31. package/dist/src/eval/corpus.d.ts +7 -0
  32. package/dist/src/eval/corpus.d.ts.map +1 -1
  33. package/dist/src/eval/corpus.js +12 -0
  34. package/dist/src/eval/ogc-corpus.d.ts +3 -0
  35. package/dist/src/eval/ogc-corpus.d.ts.map +1 -0
  36. package/dist/src/eval/ogc-corpus.js +437 -0
  37. package/dist/src/eval/runner.d.ts +6 -0
  38. package/dist/src/eval/runner.d.ts.map +1 -1
  39. package/dist/src/eval/runner.js +21 -7
  40. package/dist/src/helpers.d.ts +8 -13
  41. package/dist/src/helpers.d.ts.map +1 -1
  42. package/dist/src/helpers.js +7 -35
  43. package/dist/src/index.d.ts +36 -8
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +55 -18
  46. package/dist/src/neutral/errors.d.ts +73 -0
  47. package/dist/src/neutral/errors.d.ts.map +1 -0
  48. package/dist/src/neutral/errors.js +112 -0
  49. package/dist/src/neutral/filter.d.ts +108 -0
  50. package/dist/src/neutral/filter.d.ts.map +1 -0
  51. package/dist/src/neutral/filter.js +271 -0
  52. package/dist/src/neutral/query.d.ts +101 -0
  53. package/dist/src/neutral/query.d.ts.map +1 -0
  54. package/dist/src/neutral/query.js +180 -0
  55. package/dist/src/neutral/result.d.ts +33 -0
  56. package/dist/src/neutral/result.d.ts.map +1 -0
  57. package/dist/src/neutral/result.js +38 -0
  58. package/dist/src/neutral/source-ref.d.ts +126 -0
  59. package/dist/src/neutral/source-ref.d.ts.map +1 -0
  60. package/dist/src/neutral/source-ref.js +267 -0
  61. package/dist/src/nl-map-control.d.ts.map +1 -1
  62. package/dist/src/proxy.d.ts.map +1 -1
  63. package/dist/src/proxy.js +29 -14
  64. package/dist/src/release/zero-to-map-checkpoint.d.ts +34 -0
  65. package/dist/src/release/zero-to-map-checkpoint.d.ts.map +1 -0
  66. package/dist/src/release/zero-to-map-checkpoint.js +369 -0
  67. package/dist/src/release/zero-to-map-cli.d.ts +51 -0
  68. package/dist/src/release/zero-to-map-cli.d.ts.map +1 -0
  69. package/dist/src/release/zero-to-map-cli.js +880 -0
  70. package/dist/src/release/zero-to-map-provision.d.ts +37 -0
  71. package/dist/src/release/zero-to-map-provision.d.ts.map +1 -0
  72. package/dist/src/release/zero-to-map-provision.js +177 -0
  73. package/dist/src/release/zero-to-map.d.ts +296 -0
  74. package/dist/src/release/zero-to-map.d.ts.map +1 -0
  75. package/dist/src/release/zero-to-map.js +1069 -0
  76. package/dist/src/tools/admin-install-local.d.ts +30 -0
  77. package/dist/src/tools/admin-install-local.d.ts.map +1 -0
  78. package/dist/src/tools/admin-install-local.js +22 -0
  79. package/dist/src/tools/control-plane-command.d.ts +99 -0
  80. package/dist/src/tools/control-plane-command.d.ts.map +1 -0
  81. package/dist/src/tools/control-plane-command.js +180 -0
  82. package/dist/src/tools/count-features.d.ts +85 -11
  83. package/dist/src/tools/count-features.d.ts.map +1 -1
  84. package/dist/src/tools/count-features.js +49 -31
  85. package/dist/src/tools/describe-layer.d.ts +30 -6
  86. package/dist/src/tools/describe-layer.d.ts.map +1 -1
  87. package/dist/src/tools/describe-layer.js +99 -8
  88. package/dist/src/tools/docs-search.d.ts +100 -0
  89. package/dist/src/tools/docs-search.d.ts.map +1 -0
  90. package/dist/src/tools/docs-search.js +381 -0
  91. package/dist/src/tools/get-extent.d.ts +69 -11
  92. package/dist/src/tools/get-extent.d.ts.map +1 -1
  93. package/dist/src/tools/get-extent.js +28 -46
  94. package/dist/src/tools/list-services.d.ts +7 -0
  95. package/dist/src/tools/list-services.d.ts.map +1 -1
  96. package/dist/src/tools/list-services.js +8 -1
  97. package/dist/src/tools/list-sources.d.ts +44 -0
  98. package/dist/src/tools/list-sources.d.ts.map +1 -0
  99. package/dist/src/tools/list-sources.js +138 -0
  100. package/dist/src/tools/query-features.d.ts +99 -19
  101. package/dist/src/tools/query-features.d.ts.map +1 -1
  102. package/dist/src/tools/query-features.js +38 -45
  103. package/dist/src/tools/statistics.d.ts +80 -12
  104. package/dist/src/tools/statistics.d.ts.map +1 -1
  105. package/dist/src/tools/statistics.js +56 -23
  106. package/package.json +37 -8
  107. package/release/zero-to-map/README.md +174 -0
  108. package/release/zero-to-map/configs/claude-code.mcp.json +11 -0
  109. package/release/zero-to-map/configs/claude-desktop.json +11 -0
  110. package/release/zero-to-map/configs/cursor.json +11 -0
  111. package/release/zero-to-map/contracts/aws-ecs-provision-binding.schema.json +59 -0
  112. package/release/zero-to-map/contracts/checkpoint.schema.json +152 -0
  113. package/release/zero-to-map/contracts/console-receipt.schema.json +150 -0
  114. package/release/zero-to-map/contracts/esri-gp-mcp.v1.json +73 -0
  115. package/release/zero-to-map/fixtures/parcels.geojson +33 -0
  116. package/release/zero-to-map/fixtures/zoning.geojson +24 -0
  117. package/release/zero-to-map/journey.v1.json +1248 -0
@@ -0,0 +1,1069 @@
1
+ import { publicHttps } from "./zero-to-map-provision.js";
2
+ /**
3
+ * Contract-first runner for the 2026.1 zero-to-map release journey.
4
+ *
5
+ * The runner is deliberately adapter based. The production adapter invokes the
6
+ * existing `honua` control-plane CLI and talks to the server through the
7
+ * existing `honua-mcp-proxy`; tests use an in-memory adapter. This module does
8
+ * not contain a second installer, admin client, or MCP proxy.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { ADMIN_MCP_EXCLUDED_OPERATIONS, ADMIN_MCP_EXCLUDED_OPERATION_COUNT, ADMIN_MCP_EXCLUSION_ROSTER_SHA256, ADMIN_MCP_PUBLISHED_TOOL_NAMES, MCP_DEFAULT_STATIC_TOOL_COUNT, MCP_DEFAULT_TOTAL_TOOL_COUNT, } from "@honua/sdk-js/control-plane";
12
+ export const ZERO_TO_MAP_PLAN_SCHEMA = "honua.zero-to-map.plan/v1";
13
+ export const ZERO_TO_MAP_RECEIPT_SCHEMA = "honua.zero-to-map.receipt/v1";
14
+ export const ZERO_TO_MAP_CONSOLE_RECEIPT_SCHEMA = "honua.zero-to-map.console-receipt/v1";
15
+ export const ZERO_TO_MAP_CONSOLE_RECEIPT_REQUEST_SCHEMA = "honua.zero-to-map.console-receipt-request/v1";
16
+ export const ZERO_TO_MAP_CATALOG_RECEIPT_SCHEMA = "honua.zero-to-map.catalog/v1";
17
+ /** Identifier of the always-required default roster. */
18
+ export const ZERO_TO_MAP_BASE_PROFILE_ID = "base";
19
+ /**
20
+ * Additive profiles the zero-to-map journey requires, in receipt order.
21
+ *
22
+ * `analysis` contributes six members; only `honua_buffer_features` - the
23
+ * MCP-native dataset Buffer verb the journey actually executes - is confirmed
24
+ * by an in-repo artifact. `esri-gp` contributes exactly the three tools named
25
+ * by `release/zero-to-map/contracts/esri-gp-mcp.v1.json`.
26
+ */
27
+ export const ZERO_TO_MAP_ADDITIVE_PROFILES = [
28
+ {
29
+ id: "analysis",
30
+ memberCount: 6,
31
+ confirmedMembers: ["honua_buffer_features"],
32
+ namePrefixes: [],
33
+ },
34
+ {
35
+ id: "esri-gp",
36
+ memberCount: 3,
37
+ confirmedMembers: ["honua_esri_gp_describe_task", "honua_esri_gp_execute_task", "honua_esri_gp_list_tasks"],
38
+ namePrefixes: ["honua_esri_gp_"],
39
+ },
40
+ ];
41
+ /** Profiles the candidate must advertise before the journey is executable. */
42
+ export const ZERO_TO_MAP_REQUIRED_PROFILES = [
43
+ ZERO_TO_MAP_BASE_PROFILE_ID,
44
+ ...ZERO_TO_MAP_ADDITIVE_PROFILES.map((profile) => profile.id),
45
+ ];
46
+ /** Tools the base roster contributes: 47 static + 385 published Admin projections. */
47
+ export const ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT = MCP_DEFAULT_STATIC_TOOL_COUNT + ADMIN_MCP_PUBLISHED_TOOL_NAMES.length;
48
+ /** Derived candidate total. Never hardcode this; it follows from the enabled profiles. */
49
+ export const ZERO_TO_MAP_EXPECTED_TOTAL_TOOL_COUNT = ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT +
50
+ ZERO_TO_MAP_ADDITIVE_PROFILES.reduce((total, profile) => total + profile.memberCount, 0);
51
+ const STUDIO_GENERATION_MUTATION_TOOLS = new Set([
52
+ "honua_studio_add_control",
53
+ "honua_studio_add_layer",
54
+ "honua_studio_add_widget",
55
+ "honua_studio_bind_interaction",
56
+ "honua_studio_propose_publication",
57
+ "honua_studio_set_layer_style",
58
+ "honua_studio_set_layer_visibility",
59
+ "honua_studio_set_view",
60
+ ]);
61
+ const STUDIO_GENERATION_CAPTURE_POINTERS = ["/structuredContent/generation"];
62
+ const STUDIO_PUBLICATION_GENERATION_CAPTURE_POINTERS = [
63
+ "/structuredContent/draft/generation",
64
+ "/structuredContent/generation",
65
+ ];
66
+ export class JourneyBlockedError extends Error {
67
+ code;
68
+ constructor(message, code = "dependency-unavailable") {
69
+ super(message);
70
+ this.code = code;
71
+ this.name = "JourneyBlockedError";
72
+ }
73
+ }
74
+ /** Parse and validate the checked-in release plan without executing it. */
75
+ export function parseZeroToMapPlan(value) {
76
+ const plan = record(value, "plan");
77
+ if (plan.schemaVersion !== ZERO_TO_MAP_PLAN_SCHEMA) {
78
+ throw new Error(`plan.schemaVersion must be ${ZERO_TO_MAP_PLAN_SCHEMA}`);
79
+ }
80
+ const journeyId = nonEmptyString(plan.journeyId, "plan.journeyId");
81
+ const releaseContract = nonEmptyString(plan.releaseContract, "plan.releaseContract");
82
+ const fixtures = stringArray(plan.fixtures, "plan.fixtures");
83
+ const dependencyRefs = stringArray(plan.dependencyRefs, "plan.dependencyRefs");
84
+ if (!Array.isArray(plan.stages) || plan.stages.length !== 7) {
85
+ throw new Error("plan.stages must contain the seven D9.3 stages");
86
+ }
87
+ const actionIds = new Set();
88
+ const stages = plan.stages.map((candidate, index) => {
89
+ const stage = record(candidate, `plan.stages[${index}]`);
90
+ const number = stage.number;
91
+ if (number !== index + 1)
92
+ throw new Error(`plan.stages[${index}].number must be ${index + 1}`);
93
+ if (!Array.isArray(stage.actions) || stage.actions.length === 0) {
94
+ throw new Error(`plan.stages[${index}].actions must not be empty`);
95
+ }
96
+ const actions = stage.actions.map((action, actionIndex) => parseAction(action, `plan.stages[${index}].actions[${actionIndex}]`, actionIds));
97
+ return {
98
+ number,
99
+ id: nonEmptyString(stage.id, `plan.stages[${index}].id`),
100
+ title: nonEmptyString(stage.title, `plan.stages[${index}].title`),
101
+ actions,
102
+ };
103
+ });
104
+ const variables = plan.variables === undefined ? undefined : record(plan.variables, "plan.variables");
105
+ return {
106
+ schemaVersion: ZERO_TO_MAP_PLAN_SCHEMA,
107
+ journeyId,
108
+ releaseContract,
109
+ fixtures,
110
+ dependencyRefs,
111
+ ...(variables ? { variables } : {}),
112
+ stages,
113
+ };
114
+ }
115
+ /**
116
+ * Run a plan, recording every blocked/skipped step. Contract mode never calls
117
+ * the adapter and therefore cannot be mistaken for a live release execution.
118
+ */
119
+ export async function runZeroToMapJourney(plan, adapter, options) {
120
+ const now = options.now ?? (() => new Date());
121
+ if (options.resume && !options.execute)
122
+ throw new Error("A journey checkpoint can be resumed only in live mode.");
123
+ const configuredVariables = { ...plan.variables, ...options.variables };
124
+ const target = options.target ?? "local-docker";
125
+ const checkpointSeeds = checkpointSeedVariables(configuredVariables, target);
126
+ const resumeStageIndex = options.resume ? validateJourneyResume(plan, options.resume, options.variables, target) : 0;
127
+ const startedAt = options.resume?.startedAt ?? now().toISOString();
128
+ const capturedVariables = options.resume
129
+ ? { ...options.resume.capturedVariables }
130
+ : { ...checkpointSeeds };
131
+ const variables = {
132
+ ...configuredVariables,
133
+ journeyId: plan.journeyId,
134
+ releaseContract: plan.releaseContract,
135
+ ...capturedVariables,
136
+ };
137
+ const stages = [...(options.resume?.completedStages ?? [])];
138
+ let stop;
139
+ // Restored rather than re-derived: the preflight already ran in the pre-pause
140
+ // segment whose receipts arrive in `completedStages`, and no post-checkpoint
141
+ // stage issues an MCP action that could produce it again.
142
+ let catalogReceipt = options.resume?.catalog;
143
+ try {
144
+ for (let stageIndex = resumeStageIndex; stageIndex < plan.stages.length; stageIndex += 1) {
145
+ const stage = plan.stages[stageIndex];
146
+ if (!stage)
147
+ throw new Error(`plan stage ${stageIndex + 1} disappeared during execution`);
148
+ const actions = [];
149
+ for (let actionIndex = 0; actionIndex < stage.actions.length; actionIndex += 1) {
150
+ const action = stage.actions[actionIndex];
151
+ if (!action)
152
+ throw new Error(`plan action ${stage.id}[${actionIndex}] disappeared during execution`);
153
+ const actionStartedAt = now().toISOString();
154
+ if (stop) {
155
+ actions.push({
156
+ id: action.id,
157
+ kind: action.kind,
158
+ status: "skipped",
159
+ startedAt: actionStartedAt,
160
+ finishedAt: now().toISOString(),
161
+ code: "prerequisite-not-passed",
162
+ message: `Skipped because ${stop.actionId} was ${stop.status}.`,
163
+ });
164
+ continue;
165
+ }
166
+ if (!options.execute) {
167
+ actions.push({
168
+ id: action.id,
169
+ kind: action.kind,
170
+ status: "blocked",
171
+ startedAt: actionStartedAt,
172
+ finishedAt: now().toISOString(),
173
+ code: "live-execution-disabled",
174
+ message: "Contract mode validated the plan but did not execute Docker, CLI, MCP, Console, or HTTP work.",
175
+ });
176
+ stop = { status: "blocked", actionId: action.id };
177
+ continue;
178
+ }
179
+ try {
180
+ if (action.kind === "mcp" && !catalogReceipt) {
181
+ catalogReceipt = await assertMcpCatalog(plan, adapter);
182
+ }
183
+ const result = await executeAction(action, adapter, variables);
184
+ for (const pointer of action.forbiddenPointers ?? []) {
185
+ if (jsonPointer(result.value, pointer) !== undefined) {
186
+ throw new Error(`${action.id} disclosed forbidden pre-approval evidence at ${pointer}`);
187
+ }
188
+ }
189
+ const captures = captureValues(action.captures, result.value, variables);
190
+ Object.assign(variables, captures);
191
+ Object.assign(capturedVariables, captures);
192
+ actions.push({
193
+ id: action.id,
194
+ kind: action.kind,
195
+ status: "passed",
196
+ startedAt: actionStartedAt,
197
+ finishedAt: now().toISOString(),
198
+ ...(result.evidence ? { evidence: result.evidence } : {}),
199
+ ...(Object.keys(captures).length > 0 ? { captures } : {}),
200
+ });
201
+ }
202
+ catch (error) {
203
+ let effectiveError = error;
204
+ if (error instanceof JourneyBlockedError &&
205
+ error.code === "external-receipt-missing" &&
206
+ options.onExternalReceiptMissing) {
207
+ try {
208
+ if (action.kind !== "receipt")
209
+ throw new Error("external receipt pause requires a receipt action");
210
+ if (actionIndex !== 0) {
211
+ throw new Error("external receipt pause must occur at a stage boundary to prevent partial replay");
212
+ }
213
+ await options.onExternalReceiptMissing({
214
+ startedAt,
215
+ capturedVariables: { ...capturedVariables },
216
+ completedStages: [...stages],
217
+ resumeAt: { stageId: stage.id, actionId: action.id },
218
+ ...(catalogReceipt ? { catalog: catalogReceipt } : {}),
219
+ consoleReceiptRequest: resolveReceiptRequest(action, variables),
220
+ });
221
+ }
222
+ catch (pauseError) {
223
+ effectiveError = pauseError;
224
+ }
225
+ }
226
+ const checkpointFailure = effectiveError !== error;
227
+ const blocked = effectiveError instanceof JourneyBlockedError;
228
+ const status = blocked ? "blocked" : "failed";
229
+ actions.push({
230
+ id: action.id,
231
+ kind: action.kind,
232
+ status,
233
+ startedAt: actionStartedAt,
234
+ finishedAt: now().toISOString(),
235
+ code: effectiveError instanceof JourneyBlockedError
236
+ ? effectiveError.code
237
+ : checkpointFailure
238
+ ? "checkpoint-write-failed"
239
+ : "execution-failed",
240
+ message: effectiveError instanceof Error ? effectiveError.message : String(effectiveError),
241
+ });
242
+ stop = { status, actionId: action.id };
243
+ }
244
+ }
245
+ stages.push({
246
+ number: stage.number,
247
+ id: stage.id,
248
+ title: stage.title,
249
+ status: summarizeActions(actions),
250
+ actions,
251
+ });
252
+ }
253
+ }
254
+ finally {
255
+ await adapter.close?.();
256
+ }
257
+ const blockers = stages
258
+ .flatMap((stage) => stage.actions)
259
+ .filter((action) => action.status === "blocked")
260
+ .map((action) => `${action.id}: ${action.code ?? "blocked"}: ${action.message ?? "blocked"}`);
261
+ const status = stop?.status ?? "passed";
262
+ if (status === "passed" && blockers.length > 0) {
263
+ throw new Error("internal receipt error: a passed journey cannot contain runtime blockers");
264
+ }
265
+ return {
266
+ schemaVersion: ZERO_TO_MAP_RECEIPT_SCHEMA,
267
+ journeyId: plan.journeyId,
268
+ releaseContract: plan.releaseContract,
269
+ mode: options.execute ? "live" : "contract",
270
+ status,
271
+ startedAt,
272
+ finishedAt: now().toISOString(),
273
+ dependencyRefs: plan.dependencyRefs,
274
+ blockers,
275
+ ...(catalogReceipt ? { catalog: catalogReceipt } : {}),
276
+ stages,
277
+ };
278
+ }
279
+ /**
280
+ * Validate that a checkpoint contains exactly the passed stage prefix for this
281
+ * plan. Returning the resume stage index lets the executor preserve the prior
282
+ * receipts without replaying any mutating action.
283
+ */
284
+ export function validateJourneyResume(plan, resume, executionVariables = {}, target = "local-docker") {
285
+ if (!Number.isFinite(Date.parse(resume.startedAt)))
286
+ throw new Error("checkpoint startedAt must be an ISO timestamp");
287
+ const stageIndex = plan.stages.findIndex((stage) => stage.id === resume.resumeAt.stageId);
288
+ if (stageIndex < 0)
289
+ throw new Error(`checkpoint resume stage is not in this plan: ${resume.resumeAt.stageId}`);
290
+ const stage = plan.stages[stageIndex];
291
+ if (!stage)
292
+ throw new Error("checkpoint resume stage disappeared from the plan");
293
+ const actionIndex = stage.actions.findIndex((action) => action.id === resume.resumeAt.actionId);
294
+ if (actionIndex !== 0 || stage.actions[0]?.kind !== "receipt") {
295
+ throw new Error("checkpoint resume action must be the first receipt action in its stage");
296
+ }
297
+ if (resume.completedStages.length !== stageIndex) {
298
+ throw new Error("checkpoint completed stage prefix does not reach the declared resume point");
299
+ }
300
+ const expectedSeeds = checkpointSeedVariables({
301
+ ...plan.variables,
302
+ ...executionVariables,
303
+ }, target);
304
+ const persistedSeeds = checkpointSeedVariables(resume.capturedVariables, target);
305
+ for (const [name, value] of Object.entries(expectedSeeds)) {
306
+ if (persistedSeeds[name] !== value) {
307
+ throw new Error(`checkpoint seed ${name} does not match the current plan or execution input`);
308
+ }
309
+ }
310
+ const restoredCaptures = { ...persistedSeeds };
311
+ let previousFinishedAt = Date.parse(resume.startedAt);
312
+ for (let index = 0; index < resume.completedStages.length; index += 1) {
313
+ const actualStage = resume.completedStages[index];
314
+ const plannedStage = plan.stages[index];
315
+ if (!actualStage || !plannedStage)
316
+ throw new Error(`checkpoint completed stage ${index + 1} is missing`);
317
+ if (actualStage.number !== plannedStage.number ||
318
+ actualStage.id !== plannedStage.id ||
319
+ actualStage.title !== plannedStage.title ||
320
+ actualStage.status !== "passed" ||
321
+ actualStage.actions.length !== plannedStage.actions.length) {
322
+ throw new Error(`checkpoint completed stage ${plannedStage.id} does not match the current plan`);
323
+ }
324
+ for (let actionIndex = 0; actionIndex < actualStage.actions.length; actionIndex += 1) {
325
+ const actualAction = actualStage.actions[actionIndex];
326
+ const plannedAction = plannedStage.actions[actionIndex];
327
+ if (!actualAction ||
328
+ !plannedAction ||
329
+ actualAction.id !== plannedAction.id ||
330
+ actualAction.kind !== plannedAction.kind ||
331
+ actualAction.status !== "passed") {
332
+ throw new Error(`checkpoint action ${plannedStage.id}[${actionIndex}] is not an exact passed plan action`);
333
+ }
334
+ const actionStartedAt = Date.parse(actualAction.startedAt);
335
+ const actionFinishedAt = Date.parse(actualAction.finishedAt);
336
+ if (!Number.isFinite(actionStartedAt) ||
337
+ !Number.isFinite(actionFinishedAt) ||
338
+ actionStartedAt < previousFinishedAt ||
339
+ actionFinishedAt < actionStartedAt) {
340
+ throw new Error(`checkpoint action ${actualAction.id} is outside the completed receipt timeline`);
341
+ }
342
+ previousFinishedAt = actionFinishedAt;
343
+ const plannedCaptures = plannedAction.captures ?? [];
344
+ const actualCaptures = actualAction.captures ?? {};
345
+ const plannedCaptureNames = plannedCaptures.map((capture) => capture.variable).sort();
346
+ const actualCaptureNames = Object.keys(actualCaptures).sort();
347
+ if (stableValue(plannedCaptureNames) !== stableValue(actualCaptureNames)) {
348
+ throw new Error(`checkpoint action ${actualAction.id} does not contain its exact planned captures`);
349
+ }
350
+ const beforeAction = { ...restoredCaptures };
351
+ const captureSources = new Map();
352
+ for (const capture of plannedCaptures) {
353
+ const name = capture.variable;
354
+ const value = actualCaptures[name];
355
+ if (value === undefined)
356
+ throw new Error(`checkpoint action ${actualAction.id} has undefined capture ${name}`);
357
+ if (Object.hasOwn(restoredCaptures, name)) {
358
+ assertMutableGenerationAdvance(plannedAction, capture, restoredCaptures[name], value);
359
+ }
360
+ const expected = typeof capture.equals === "string"
361
+ ? resolveTemplateValue(capture.equals, {
362
+ ...plan.variables,
363
+ ...executionVariables,
364
+ ...beforeAction,
365
+ })
366
+ : capture.equals;
367
+ if (expected !== undefined && stableValue(value) !== stableValue(expected)) {
368
+ throw new Error(`checkpoint capture ${name} does not match its planned value: expected ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`);
369
+ }
370
+ const sourceKey = stableValue({ pointers: capture.pointers, parsedPointers: capture.parsedPointers });
371
+ const priorSource = captureSources.get(sourceKey);
372
+ if (priorSource && stableValue(priorSource.value) !== stableValue(value)) {
373
+ throw new Error(`checkpoint action ${actualAction.id} captured different values from the same response source for ${priorSource.variable} and ${name}`);
374
+ }
375
+ captureSources.set(sourceKey, { variable: name, value });
376
+ restoredCaptures[name] = value;
377
+ }
378
+ }
379
+ }
380
+ if (stableValue(restoredCaptures) !== stableValue(resume.capturedVariables)) {
381
+ throw new Error("checkpoint captured variables do not match the completed action receipts");
382
+ }
383
+ return stageIndex;
384
+ }
385
+ /**
386
+ * Studio draft generations are the only intentionally replaceable checkpoint
387
+ * capture. The plan must explicitly consume the prior value and the response
388
+ * must advance it by exactly one; immutable ids, hashes, and names remain
389
+ * single-assignment.
390
+ */
391
+ function assertMutableGenerationAdvance(action, capture, previous, next) {
392
+ const name = capture.variable;
393
+ const generationSuffix = "Generation";
394
+ if (action.kind !== "mcp" ||
395
+ !STUDIO_GENERATION_MUTATION_TOOLS.has(action.tool) ||
396
+ !name.endsWith(generationSuffix) ||
397
+ name.length === generationSuffix.length) {
398
+ throw new Error(`checkpoint capture ${name} is duplicated`);
399
+ }
400
+ const draftIdName = `${name.slice(0, -generationSuffix.length)}DraftId`;
401
+ const expectedPointers = action.tool === "honua_studio_propose_publication"
402
+ ? STUDIO_PUBLICATION_GENERATION_CAPTURE_POINTERS
403
+ : STUDIO_GENERATION_CAPTURE_POINTERS;
404
+ if (action.arguments.generation !== `\${${name}}` ||
405
+ action.arguments.draftId !== `\${${draftIdName}}` ||
406
+ capture.parsedPointers !== undefined ||
407
+ stableValue(capture.pointers) !== stableValue(expectedPointers)) {
408
+ throw new Error(`checkpoint mutable capture ${name} is not the same Studio draft generation stream`);
409
+ }
410
+ if (!Number.isSafeInteger(previous) ||
411
+ previous < 1 ||
412
+ !Number.isSafeInteger(next) ||
413
+ next !== previous + 1) {
414
+ throw new Error(`checkpoint mutable capture ${name} must advance by exactly one; got ${JSON.stringify(previous)} -> ${JSON.stringify(next)}`);
415
+ }
416
+ }
417
+ /** Persist only validated, non-secret deterministic inputs needed by external evidence producers. */
418
+ function checkpointSeedVariables(variables, target) {
419
+ const seeds = {};
420
+ const serviceName = variables.serviceName;
421
+ if (serviceName !== undefined) {
422
+ if (typeof serviceName !== "string" || serviceName.trim().length === 0) {
423
+ throw new Error("serviceName must be a non-empty string when checkpointed");
424
+ }
425
+ seeds.serviceName = serviceName;
426
+ }
427
+ const fixtureBaseUrl = variables.fixtureBaseUrl;
428
+ if (fixtureBaseUrl !== undefined) {
429
+ seeds.fixtureBaseUrl = checkpointFixtureBaseUrl(fixtureBaseUrl, target);
430
+ }
431
+ return seeds;
432
+ }
433
+ function checkpointFixtureBaseUrl(value, target) {
434
+ if (target === "aws-ecs")
435
+ return publicHttps(value, "fixtureBaseUrl").replace(/\/$/, "");
436
+ try {
437
+ return publicHttps(value, "fixtureBaseUrl").replace(/\/$/, "");
438
+ }
439
+ catch (publicError) {
440
+ if (typeof value !== "string" || !value)
441
+ throw publicError;
442
+ const url = new URL(value);
443
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
444
+ if (url.protocol !== "http:" ||
445
+ host !== "host.docker.internal" ||
446
+ url.port !== "4173" ||
447
+ url.username ||
448
+ url.password ||
449
+ url.search ||
450
+ url.hash) {
451
+ throw new Error("fixtureBaseUrl must be public HTTPS or the local Docker fixture origin http://host.docker.internal:4173");
452
+ }
453
+ return url.toString().replace(/\/$/, "");
454
+ }
455
+ }
456
+ function resolveReceiptRequest(action, variables) {
457
+ return {
458
+ schemaVersion: ZERO_TO_MAP_CONSOLE_RECEIPT_REQUEST_SCHEMA,
459
+ actionId: action.id,
460
+ receiptSchema: action.receiptSchema,
461
+ matches: Object.fromEntries(Object.entries(action.matches).map(([pointer, template]) => [pointer, resolveTemplateValue(template, variables)])),
462
+ requiredPointers: [...action.requiredPointers],
463
+ equalPointers: (action.equalPointers ?? []).map(([left, right]) => [left, right]),
464
+ };
465
+ }
466
+ function stableValue(value) {
467
+ if (value === undefined)
468
+ return "undefined";
469
+ if (Array.isArray(value))
470
+ return `[${value.map(stableValue).join(",")}]`;
471
+ if (value && typeof value === "object") {
472
+ const object = value;
473
+ return `{${Object.keys(object)
474
+ .sort()
475
+ .map((key) => `${JSON.stringify(key)}:${stableValue(object[key])}`)
476
+ .join(",")}}`;
477
+ }
478
+ return JSON.stringify(value);
479
+ }
480
+ function requiredMcpToolUses(plan) {
481
+ return plan.stages.flatMap((stage) => stage.actions
482
+ .filter((action) => action.kind === "mcp")
483
+ .map((action) => ({
484
+ tool: action.tool,
485
+ stageNumber: stage.number,
486
+ stageId: stage.id,
487
+ actionId: action.id,
488
+ })));
489
+ }
490
+ function describeToolUse(use) {
491
+ return `${use.tool} (stage ${use.stageNumber} ${use.stageId}, action ${use.actionId})`;
492
+ }
493
+ function rosterDigest(names) {
494
+ return createHash("sha256")
495
+ .update(`${[...names].sort().join("\n")}\n`)
496
+ .digest("hex");
497
+ }
498
+ function summarizeNames(names, limit = 10) {
499
+ if (names.length === 0)
500
+ return "none";
501
+ const sorted = [...names].sort();
502
+ return sorted.length > limit
503
+ ? `${sorted.slice(0, limit).join(", ")}, ... (+${sorted.length - limit})`
504
+ : sorted.join(", ");
505
+ }
506
+ /**
507
+ * Preflight the advertised MCP catalog before the first live mutation.
508
+ *
509
+ * The base roster, the audited exclusions and each enabled server profile are
510
+ * verified independently, and the expected total is *derived* from those
511
+ * rosters rather than written down. A previous revision required the catalog
512
+ * to equal the 432-tool default roster while the journey simultaneously
513
+ * required analysis and Esri GP tools, so no candidate configuration could
514
+ * pass. Findings are reported per cause (missing / unexpected / duplicate /
515
+ * excluded / truncated / drifted) and name the journey stage and action that
516
+ * needs the tool.
517
+ */
518
+ async function assertMcpCatalog(plan, adapter) {
519
+ const requiredUses = requiredMcpToolUses(plan);
520
+ const catalog = await adapter.listTools();
521
+ const names = catalog.map((tool) => tool.name);
522
+ const findings = [];
523
+ const duplicateNames = duplicateValues(names);
524
+ if (duplicateNames.length > 0) {
525
+ findings.push(`duplicate: the catalog advertises ${summarizeNames(duplicateNames)} more than once`);
526
+ }
527
+ // --- base profile: the 385 published Admin projections ---------------------
528
+ const advertisedAdmin = names.filter((name) => name.startsWith("honua_admin_"));
529
+ const advertisedAdminSet = new Set(advertisedAdmin);
530
+ const expectedAdmin = new Set(ADMIN_MCP_PUBLISHED_TOOL_NAMES);
531
+ const missingAdmin = [...expectedAdmin].filter((name) => !advertisedAdminSet.has(name)).sort();
532
+ const unexpectedAdmin = [...advertisedAdminSet].filter((name) => !expectedAdmin.has(name)).sort();
533
+ if (missingAdmin.length > 0) {
534
+ findings.push(`missing: ${missingAdmin.length} of the ${expectedAdmin.size} published Admin projections are absent ` +
535
+ `(${summarizeNames(missingAdmin)})`);
536
+ }
537
+ if (unexpectedAdmin.length > 0) {
538
+ findings.push(`unexpected: the catalog advertises unpublished Admin tools ${summarizeNames(unexpectedAdmin)}`);
539
+ }
540
+ // --- base profile: the 11 audited secret/session exclusions ----------------
541
+ const excludedToolNames = ADMIN_MCP_EXCLUDED_OPERATIONS.map((operation) => operation.toolName);
542
+ if (excludedToolNames.length !== ADMIN_MCP_EXCLUDED_OPERATION_COUNT) {
543
+ findings.push(`excluded: the generated exclusion roster carries ${excludedToolNames.length} operations; the audited ` +
544
+ `contract requires ${ADMIN_MCP_EXCLUDED_OPERATION_COUNT}`);
545
+ }
546
+ const advertisedExclusions = excludedToolNames.filter((name) => advertisedAdminSet.has(name)).sort();
547
+ if (advertisedExclusions.length > 0) {
548
+ findings.push(`excluded: the catalog advertises audited secret/session operations ${summarizeNames(advertisedExclusions)}`);
549
+ }
550
+ // --- additive profiles, verified independently -----------------------------
551
+ const nonAdminNames = names.filter((name) => !name.startsWith("honua_admin_"));
552
+ const namedProfiles = ZERO_TO_MAP_ADDITIVE_PROFILES.filter((profile) => profile.namePrefixes.length > 0);
553
+ const fallbackProfile = ZERO_TO_MAP_ADDITIVE_PROFILES.find((profile) => profile.namePrefixes.length === 0);
554
+ const claimed = new Set();
555
+ const profileReceipts = [];
556
+ for (const profile of namedProfiles) {
557
+ const members = nonAdminNames.filter((name) => profile.namePrefixes.some((prefix) => name.startsWith(prefix)));
558
+ for (const member of members)
559
+ claimed.add(member);
560
+ const memberSet = new Set(members);
561
+ const missingMembers = profile.confirmedMembers.filter((name) => !memberSet.has(name)).sort();
562
+ const unexpectedMembers = [...memberSet].filter((name) => !profile.confirmedMembers.includes(name)).sort();
563
+ if (missingMembers.length > 0) {
564
+ findings.push(`missing: the ${profile.id} profile is not advertised - ${missingMembers
565
+ .map((tool) => {
566
+ const use = requiredUses.find((candidate) => candidate.tool === tool);
567
+ return use ? describeToolUse(use) : tool;
568
+ })
569
+ .join(", ")}. Enable the ${profile.id} server profile on the candidate and rerun.`);
570
+ }
571
+ if (unexpectedMembers.length > 0) {
572
+ findings.push(`unexpected: the ${profile.id} profile advertises ${summarizeNames(unexpectedMembers)}, which the checked-in profile contract does not declare`);
573
+ }
574
+ if (members.length !== profile.memberCount) {
575
+ findings.push(`${members.length < profile.memberCount ? "missing" : "unexpected"}: the ${profile.id} profile advertises ` +
576
+ `${members.length} members; the profile contract declares ${profile.memberCount}`);
577
+ }
578
+ profileReceipts.push({
579
+ id: profile.id,
580
+ expectedMembers: profile.memberCount,
581
+ advertisedMembers: members.length,
582
+ confirmedMembers: [...profile.confirmedMembers].sort(),
583
+ nameResolvedMembers: [...members].sort(),
584
+ rosterSha256: rosterDigest(members),
585
+ });
586
+ }
587
+ // The fallback profile's members are not separable from the base static
588
+ // surface by name (the server has not published an `analysis` roster yet:
589
+ // honua-server#3363). It is therefore verified by its confirmed members plus
590
+ // its declared size, and the base static count is derived from the residue.
591
+ const residue = nonAdminNames.filter((name) => !claimed.has(name));
592
+ const fallbackMembers = fallbackProfile ? residue.length - MCP_DEFAULT_STATIC_TOOL_COUNT : 0;
593
+ const baseStaticTools = residue.length - fallbackMembers;
594
+ if (fallbackProfile) {
595
+ const residueSet = new Set(residue);
596
+ const missingMembers = fallbackProfile.confirmedMembers.filter((name) => !residueSet.has(name)).sort();
597
+ if (missingMembers.length > 0) {
598
+ findings.push(`missing: the ${fallbackProfile.id} profile is not advertised - ${missingMembers
599
+ .map((tool) => {
600
+ const use = requiredUses.find((candidate) => candidate.tool === tool);
601
+ return use ? describeToolUse(use) : tool;
602
+ })
603
+ .join(", ")}. Enable the ${fallbackProfile.id} server profile on the candidate and rerun.`);
604
+ }
605
+ if (fallbackMembers !== fallbackProfile.memberCount) {
606
+ findings.push(`${fallbackMembers < fallbackProfile.memberCount ? "missing" : "unexpected"}: the non-Admin surface ` +
607
+ `advertises ${residue.length} tools; the base static roster (${MCP_DEFAULT_STATIC_TOOL_COUNT}) plus the ` +
608
+ `${fallbackProfile.id} profile (${fallbackProfile.memberCount}) derive ` +
609
+ `${MCP_DEFAULT_STATIC_TOOL_COUNT + fallbackProfile.memberCount}`);
610
+ }
611
+ profileReceipts.push({
612
+ id: fallbackProfile.id,
613
+ expectedMembers: fallbackProfile.memberCount,
614
+ advertisedMembers: Math.max(fallbackMembers, 0),
615
+ confirmedMembers: [...fallbackProfile.confirmedMembers].sort(),
616
+ nameResolvedMembers: [...fallbackProfile.confirmedMembers].filter((name) => residueSet.has(name)).sort(),
617
+ rosterSha256: rosterDigest(residue),
618
+ });
619
+ }
620
+ // --- derived totals --------------------------------------------------------
621
+ if (ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT !== MCP_DEFAULT_TOTAL_TOOL_COUNT) {
622
+ findings.push(`unexpected: the generated base roster derives ${MCP_DEFAULT_STATIC_TOOL_COUNT} static + ${expectedAdmin.size} ` +
623
+ `Admin = ${ZERO_TO_MAP_BASE_PROFILE_TOOL_COUNT} tools, but the default roster constant is ` +
624
+ `${MCP_DEFAULT_TOTAL_TOOL_COUNT}`);
625
+ }
626
+ const expectedTotal = ZERO_TO_MAP_EXPECTED_TOTAL_TOOL_COUNT;
627
+ if (names.length !== expectedTotal) {
628
+ const shortfall = expectedTotal - names.length;
629
+ findings.push(shortfall > 0 && duplicateNames.length === 0 && unexpectedAdmin.length === 0
630
+ ? `truncated: the catalog returned ${names.length} of ${expectedTotal} tools (${shortfall} short). Every tools/list page must be drained before the roster is judged; a short read is a pagination fault, not a roster decision.`
631
+ : `unexpected: the catalog advertises ${names.length} tools; the enabled profiles ` +
632
+ `(${ZERO_TO_MAP_REQUIRED_PROFILES.join(" + ")}) derive ${expectedTotal}`);
633
+ }
634
+ if (findings.length > 0) {
635
+ throw new JourneyBlockedError(`MCP catalog preflight failed for profiles ${ZERO_TO_MAP_REQUIRED_PROFILES.join(" + ")}: ${findings.join("; ")}`, "mcp-catalog-incomplete");
636
+ }
637
+ // --- every tool the plan actually calls ------------------------------------
638
+ const advertised = new Map(catalog.map((tool) => [tool.name, tool]));
639
+ const missingRequired = [...new Set(requiredUses.map((use) => use.tool))].filter((tool) => !advertised.has(tool));
640
+ if (missingRequired.length > 0) {
641
+ throw new JourneyBlockedError(`MCP catalog is missing required tools: ${missingRequired.join(", ")}`, "mcp-catalog-incomplete");
642
+ }
643
+ // --- input-schema drift on every planned call ------------------------------
644
+ for (const stage of plan.stages) {
645
+ for (const action of stage.actions) {
646
+ if (action.kind !== "mcp")
647
+ continue;
648
+ const descriptor = advertised.get(action.tool);
649
+ if (!descriptor)
650
+ continue;
651
+ try {
652
+ assertArgumentShape(action.arguments, descriptor.inputSchema, action.tool);
653
+ }
654
+ catch (error) {
655
+ const use = {
656
+ tool: action.tool,
657
+ stageNumber: stage.number,
658
+ stageId: stage.id,
659
+ actionId: action.id,
660
+ };
661
+ throw new JourneyBlockedError(`MCP input contract mismatch for ${describeToolUse(use)}: ${error instanceof Error ? error.message : String(error)}`, "mcp-input-contract-mismatch");
662
+ }
663
+ }
664
+ }
665
+ return {
666
+ schemaVersion: ZERO_TO_MAP_CATALOG_RECEIPT_SCHEMA,
667
+ activeProfiles: [...ZERO_TO_MAP_REQUIRED_PROFILES],
668
+ expectedTotalTools: expectedTotal,
669
+ advertisedTotalTools: names.length,
670
+ baseStaticTools,
671
+ baseAdminTools: advertisedAdmin.length,
672
+ auditedExclusions: excludedToolNames.length,
673
+ profiles: profileReceipts,
674
+ catalogSha256: rosterDigest(names),
675
+ adminRosterSha256: rosterDigest(advertisedAdmin),
676
+ staticRosterSha256: rosterDigest(residue),
677
+ exclusionRosterSha256: ADMIN_MCP_EXCLUSION_ROSTER_SHA256,
678
+ };
679
+ }
680
+ function duplicateValues(values) {
681
+ const seen = new Set();
682
+ const duplicates = new Set();
683
+ for (const value of values) {
684
+ if (seen.has(value))
685
+ duplicates.add(value);
686
+ else
687
+ seen.add(value);
688
+ }
689
+ return [...duplicates].sort();
690
+ }
691
+ function assertArgumentShape(value, schemaValue, path) {
692
+ if (!schemaValue || typeof schemaValue !== "object" || Array.isArray(schemaValue))
693
+ return;
694
+ const schema = schemaValue;
695
+ if (typeof value === "string" && value.includes("${"))
696
+ return;
697
+ if (Array.isArray(schema.oneOf)) {
698
+ const matches = schema.oneOf.some((candidate) => {
699
+ try {
700
+ assertArgumentShape(value, candidate, path);
701
+ return true;
702
+ }
703
+ catch {
704
+ return false;
705
+ }
706
+ });
707
+ if (!matches)
708
+ throw new Error(`${path} does not match any oneOf branch`);
709
+ return;
710
+ }
711
+ if (schema.type === "object" || schema.properties) {
712
+ if (!value || typeof value !== "object" || Array.isArray(value))
713
+ throw new Error(`${path} must be an object`);
714
+ const object = value;
715
+ const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)
716
+ ? schema.properties
717
+ : {};
718
+ const required = Array.isArray(schema.required)
719
+ ? schema.required.filter((item) => typeof item === "string")
720
+ : [];
721
+ for (const name of required) {
722
+ if (!Object.hasOwn(object, name))
723
+ throw new Error(`${path}.${name} is required`);
724
+ }
725
+ if (schema.additionalProperties === false) {
726
+ const unexpected = Object.keys(object).filter((name) => !Object.hasOwn(properties, name));
727
+ if (unexpected.length > 0)
728
+ throw new Error(`${path} has unexpected properties: ${unexpected.join(", ")}`);
729
+ }
730
+ for (const [name, item] of Object.entries(object)) {
731
+ if (properties[name] !== undefined)
732
+ assertArgumentShape(item, properties[name], `${path}.${name}`);
733
+ }
734
+ return;
735
+ }
736
+ if (schema.type === "array") {
737
+ if (!Array.isArray(value))
738
+ throw new Error(`${path} must be an array`);
739
+ for (const [index, item] of value.entries())
740
+ assertArgumentShape(item, schema.items, `${path}[${index}]`);
741
+ return;
742
+ }
743
+ if (schema.type === "string" && typeof value !== "string")
744
+ throw new Error(`${path} must be a string`);
745
+ if (schema.type === "boolean" && typeof value !== "boolean")
746
+ throw new Error(`${path} must be a boolean`);
747
+ if (schema.type === "number" && typeof value !== "number")
748
+ throw new Error(`${path} must be a number`);
749
+ if (schema.type === "integer" && (!Number.isInteger(value) || typeof value !== "number")) {
750
+ throw new Error(`${path} must be an integer`);
751
+ }
752
+ }
753
+ async function executeAction(action, adapter, variables) {
754
+ switch (action.kind) {
755
+ case "cli":
756
+ return adapter.runCli(resolveTemplates(action.args, variables));
757
+ case "mcp":
758
+ return adapter.callTool(action.tool, resolveTemplates(action.arguments, variables));
759
+ case "mcp-resource":
760
+ return adapter.readResource({
761
+ ...action,
762
+ uri: resolveTemplateString(action.uri, variables),
763
+ });
764
+ case "gpserver":
765
+ return adapter.runGpServer({
766
+ ...action,
767
+ serviceId: resolveTemplateString(action.serviceId, variables),
768
+ taskName: resolveTemplateString(action.taskName, variables),
769
+ processId: resolveTemplateString(action.processId, variables),
770
+ parameters: resolveTemplates(action.parameters, variables),
771
+ resultNames: resolveTemplates(action.resultNames, variables),
772
+ });
773
+ case "receipt": {
774
+ const receipt = await adapter.readReceipt(action.id);
775
+ if (!receipt) {
776
+ throw new JourneyBlockedError(`No external receipt was supplied for ${action.id}; Console review/approval was not claimed.`, "external-receipt-missing");
777
+ }
778
+ const body = record(receipt.value, `${action.id} receipt`);
779
+ if (body.schemaVersion !== action.receiptSchema || body.status !== "passed") {
780
+ throw new Error(`${action.id} receipt must use ${action.receiptSchema} and have status=passed`);
781
+ }
782
+ for (const [pointer, template] of Object.entries(action.matches)) {
783
+ const actual = jsonPointer(body, pointer);
784
+ const expected = resolveTemplateValue(template, variables);
785
+ if (actual !== expected) {
786
+ throw new Error(`${action.id} receipt identity mismatch at ${pointer}`);
787
+ }
788
+ }
789
+ for (const pointer of action.requiredPointers) {
790
+ const actual = jsonPointer(body, pointer);
791
+ if (actual === undefined || actual === null || actual === "") {
792
+ throw new Error(`${action.id} receipt is missing required evidence at ${pointer}`);
793
+ }
794
+ }
795
+ for (const [leftPointer, rightPointer] of action.equalPointers ?? []) {
796
+ if (jsonPointer(body, leftPointer) !== jsonPointer(body, rightPointer)) {
797
+ throw new Error(`${action.id} receipt identity mismatch between ${leftPointer} and ${rightPointer}`);
798
+ }
799
+ }
800
+ return receipt;
801
+ }
802
+ case "http":
803
+ return adapter.checkHttp(resolveTemplateString(action.url, variables), action.expectedStatus);
804
+ }
805
+ }
806
+ function parseAction(value, path, ids) {
807
+ const action = record(value, path);
808
+ const id = nonEmptyString(action.id, `${path}.id`);
809
+ if (ids.has(id))
810
+ throw new Error(`${path}.id duplicates ${id}`);
811
+ ids.add(id);
812
+ const title = nonEmptyString(action.title, `${path}.title`);
813
+ const kind = action.kind;
814
+ if (kind !== "cli" &&
815
+ kind !== "mcp" &&
816
+ kind !== "mcp-resource" &&
817
+ kind !== "gpserver" &&
818
+ kind !== "receipt" &&
819
+ kind !== "http") {
820
+ throw new Error(`${path}.kind is not supported`);
821
+ }
822
+ const captures = parseCaptures(action.captures, `${path}.captures`);
823
+ const forbiddenPointers = action.forbiddenPointers === undefined
824
+ ? undefined
825
+ : stringArray(action.forbiddenPointers, `${path}.forbiddenPointers`);
826
+ const common = {
827
+ id,
828
+ title,
829
+ kind,
830
+ ...(captures ? { captures } : {}),
831
+ ...(forbiddenPointers ? { forbiddenPointers } : {}),
832
+ };
833
+ switch (kind) {
834
+ case "cli":
835
+ return { ...common, kind, args: stringArray(action.args, `${path}.args`) };
836
+ case "mcp":
837
+ return {
838
+ ...common,
839
+ kind,
840
+ tool: nonEmptyString(action.tool, `${path}.tool`),
841
+ arguments: record(action.arguments, `${path}.arguments`),
842
+ };
843
+ case "mcp-resource":
844
+ return {
845
+ ...common,
846
+ kind,
847
+ uri: nonEmptyString(action.uri, `${path}.uri`),
848
+ ...(action.waitFor === undefined ? {} : { waitFor: parseResourceWait(action.waitFor, `${path}.waitFor`) }),
849
+ };
850
+ case "gpserver":
851
+ return {
852
+ ...common,
853
+ kind,
854
+ serviceId: nonEmptyString(action.serviceId, `${path}.serviceId`),
855
+ taskName: nonEmptyString(action.taskName, `${path}.taskName`),
856
+ processId: nonEmptyString(action.processId, `${path}.processId`),
857
+ parameters: record(action.parameters, `${path}.parameters`),
858
+ resultNames: stringArray(action.resultNames, `${path}.resultNames`),
859
+ };
860
+ case "receipt":
861
+ return {
862
+ ...common,
863
+ kind,
864
+ receiptSchema: nonEmptyString(action.receiptSchema, `${path}.receiptSchema`),
865
+ matches: stringRecord(action.matches, `${path}.matches`),
866
+ requiredPointers: stringArray(action.requiredPointers, `${path}.requiredPointers`),
867
+ ...(action.equalPointers === undefined
868
+ ? {}
869
+ : { equalPointers: pointerPairs(action.equalPointers, `${path}.equalPointers`) }),
870
+ };
871
+ case "http": {
872
+ if (!Number.isInteger(action.expectedStatus))
873
+ throw new Error(`${path}.expectedStatus must be an integer`);
874
+ return {
875
+ ...common,
876
+ kind,
877
+ url: nonEmptyString(action.url, `${path}.url`),
878
+ expectedStatus: action.expectedStatus,
879
+ };
880
+ }
881
+ }
882
+ }
883
+ function parseResourceWait(value, path) {
884
+ const wait = record(value, path);
885
+ const terminal = stringArray(wait.terminal, `${path}.terminal`);
886
+ const equals = nonEmptyString(wait.equals, `${path}.equals`);
887
+ if (!terminal.includes(equals))
888
+ throw new Error(`${path}.terminal must include ${equals}`);
889
+ if (!Number.isInteger(wait.pollIntervalMs) || wait.pollIntervalMs < 1) {
890
+ throw new Error(`${path}.pollIntervalMs must be a positive integer`);
891
+ }
892
+ if (!Number.isInteger(wait.deadlineMs) || wait.deadlineMs < 1) {
893
+ throw new Error(`${path}.deadlineMs must be a positive integer`);
894
+ }
895
+ return {
896
+ pointer: nonEmptyString(wait.pointer, `${path}.pointer`),
897
+ equals,
898
+ terminal,
899
+ pollIntervalMs: wait.pollIntervalMs,
900
+ deadlineMs: wait.deadlineMs,
901
+ };
902
+ }
903
+ function pointerPairs(value, path) {
904
+ if (!Array.isArray(value))
905
+ throw new Error(`${path} must be an array`);
906
+ return value.map((candidate, index) => {
907
+ if (!Array.isArray(candidate) ||
908
+ candidate.length !== 2 ||
909
+ candidate.some((pointer) => typeof pointer !== "string" || !pointer.startsWith("/"))) {
910
+ throw new Error(`${path}[${index}] must contain two JSON pointers`);
911
+ }
912
+ return [candidate[0], candidate[1]];
913
+ });
914
+ }
915
+ function parseCaptures(value, path) {
916
+ if (value === undefined)
917
+ return undefined;
918
+ if (!Array.isArray(value))
919
+ throw new Error(`${path} must be an array`);
920
+ const variables = new Set();
921
+ return value.map((candidate, index) => {
922
+ const capture = record(candidate, `${path}[${index}]`);
923
+ const pointers = stringArray(capture.pointers, `${path}[${index}].pointers`);
924
+ if (pointers.length === 0)
925
+ throw new Error(`${path}[${index}].pointers must not be empty`);
926
+ const parsedPointers = capture.parsedPointers === undefined
927
+ ? undefined
928
+ : stringArray(capture.parsedPointers, `${path}[${index}].parsedPointers`);
929
+ const equals = capture.equals;
930
+ if (equals !== undefined &&
931
+ typeof equals !== "string" &&
932
+ typeof equals !== "number" &&
933
+ typeof equals !== "boolean") {
934
+ throw new Error(`${path}[${index}].equals must be a string, number, or boolean`);
935
+ }
936
+ const variable = nonEmptyString(capture.variable, `${path}[${index}].variable`);
937
+ if (variables.has(variable))
938
+ throw new Error(`${path}[${index}].variable duplicates ${variable}`);
939
+ variables.add(variable);
940
+ return {
941
+ variable,
942
+ pointers,
943
+ ...(parsedPointers ? { parsedPointers } : {}),
944
+ ...(equals !== undefined ? { equals } : {}),
945
+ };
946
+ });
947
+ }
948
+ function captureValues(captures, value, variables) {
949
+ const result = {};
950
+ for (const capture of captures ?? []) {
951
+ let captured;
952
+ for (const pointer of capture.pointers) {
953
+ const source = jsonPointer(value, pointer);
954
+ if (source === undefined)
955
+ continue;
956
+ if (!capture.parsedPointers) {
957
+ captured = source;
958
+ break;
959
+ }
960
+ if (typeof source !== "string")
961
+ continue;
962
+ let parsed;
963
+ try {
964
+ parsed = JSON.parse(source);
965
+ }
966
+ catch {
967
+ continue;
968
+ }
969
+ for (const parsedPointer of capture.parsedPointers) {
970
+ captured = jsonPointer(parsed, parsedPointer);
971
+ if (captured !== undefined)
972
+ break;
973
+ }
974
+ if (captured !== undefined)
975
+ break;
976
+ }
977
+ if (captured === undefined) {
978
+ throw new Error(`response did not contain ${capture.variable} at ${capture.pointers.join(" or ")}`);
979
+ }
980
+ const expected = typeof capture.equals === "string" ? resolveTemplateValue(capture.equals, variables) : capture.equals;
981
+ if (expected !== undefined && stableValue(captured) !== stableValue(expected)) {
982
+ throw new Error(`response contract mismatch for ${capture.variable}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(captured)}`);
983
+ }
984
+ result[capture.variable] = captured;
985
+ }
986
+ return result;
987
+ }
988
+ function jsonPointer(value, pointer) {
989
+ if (pointer === "")
990
+ return value;
991
+ if (!pointer.startsWith("/"))
992
+ throw new Error(`invalid JSON pointer: ${pointer}`);
993
+ let current = value;
994
+ for (const raw of pointer.slice(1).split("/")) {
995
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
996
+ if (Array.isArray(current)) {
997
+ if (!/^(0|[1-9]\d*)$/.test(key))
998
+ return undefined;
999
+ current = current[Number(key)];
1000
+ continue;
1001
+ }
1002
+ if (!current || typeof current !== "object")
1003
+ return undefined;
1004
+ current = current[key];
1005
+ }
1006
+ return current;
1007
+ }
1008
+ function resolveTemplates(value, variables) {
1009
+ if (typeof value === "string")
1010
+ return resolveTemplateValue(value, variables);
1011
+ if (Array.isArray(value))
1012
+ return value.map((item) => resolveTemplates(item, variables));
1013
+ if (value && typeof value === "object") {
1014
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveTemplates(item, variables)]));
1015
+ }
1016
+ return value;
1017
+ }
1018
+ function resolveTemplateValue(value, variables) {
1019
+ const exact = /^\$\{([A-Za-z][A-Za-z0-9_]*)\}$/.exec(value);
1020
+ if (exact)
1021
+ return requireVariable(exact[1], variables);
1022
+ return value.replaceAll(/\$\{([A-Za-z][A-Za-z0-9_]*)\}/g, (_match, name) => String(requireVariable(name, variables)));
1023
+ }
1024
+ function resolveTemplateString(value, variables) {
1025
+ const resolved = resolveTemplateValue(value, variables);
1026
+ if (typeof resolved !== "string")
1027
+ throw new Error(`template ${value} did not resolve to a string`);
1028
+ return resolved;
1029
+ }
1030
+ function requireVariable(name, variables) {
1031
+ if (!Object.hasOwn(variables, name) || variables[name] === undefined || variables[name] === "") {
1032
+ throw new JourneyBlockedError(`Required journey variable ${name} was not supplied.`, "journey-variable-missing");
1033
+ }
1034
+ return variables[name];
1035
+ }
1036
+ function summarizeActions(actions) {
1037
+ if (actions.some((action) => action.status === "failed"))
1038
+ return "failed";
1039
+ if (actions.some((action) => action.status === "blocked"))
1040
+ return "blocked";
1041
+ if (actions.every((action) => action.status === "skipped"))
1042
+ return "skipped";
1043
+ if (actions.some((action) => action.status === "skipped"))
1044
+ return "skipped";
1045
+ return "passed";
1046
+ }
1047
+ function record(value, path) {
1048
+ if (!value || typeof value !== "object" || Array.isArray(value))
1049
+ throw new Error(`${path} must be an object`);
1050
+ return value;
1051
+ }
1052
+ function nonEmptyString(value, path) {
1053
+ if (typeof value !== "string" || value.trim() === "")
1054
+ throw new Error(`${path} must be a non-empty string`);
1055
+ return value;
1056
+ }
1057
+ function stringArray(value, path) {
1058
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) {
1059
+ throw new Error(`${path} must be an array of non-empty strings`);
1060
+ }
1061
+ return value;
1062
+ }
1063
+ function stringRecord(value, path) {
1064
+ const candidate = record(value, path);
1065
+ if (Object.values(candidate).some((item) => typeof item !== "string" || item.length === 0)) {
1066
+ throw new Error(`${path} values must be non-empty strings`);
1067
+ }
1068
+ return candidate;
1069
+ }