@aifabrix/builder 2.60.0 → 2.61.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 (112) hide show
  1. package/README.md +14 -11
  2. package/docs/README.md +80 -0
  3. package/docs/builder-help/evidence-patterns.json +155 -0
  4. package/docs/builder-help/golden-examples/crm-company.json +30 -0
  5. package/docs/builder-help/golden-examples/crm-deal.json +29 -0
  6. package/docs/builder-help/golden-examples/document-storage-keyed-get.json +85 -0
  7. package/docs/builder-help/golden-examples/document-storage.json +30 -0
  8. package/docs/builder-help/golden-examples/meeting-transcript.json +29 -0
  9. package/docs/builder-help/golden-examples/repository-template.json +29 -0
  10. package/docs/builder-help/golden-examples/service-ticket.json +29 -0
  11. package/docs/builder-help/platform-roles.json +98 -0
  12. package/docs/builder-help/resource-type-catalog.json +402 -0
  13. package/lib/api/configuration.api.js +131 -0
  14. package/lib/api/system-secrets.api.js +72 -0
  15. package/lib/app/run-docker-fallback.js +6 -1
  16. package/lib/app/run-helpers.js +2 -1
  17. package/lib/app/run-parameter-sync.js +142 -0
  18. package/lib/build/docker-build-args.js +5 -2
  19. package/lib/build/index.js +3 -2
  20. package/lib/build/standard-docker-build.js +2 -2
  21. package/lib/cli/setup-app.js +5 -0
  22. package/lib/cli/setup-environment.js +156 -0
  23. package/lib/commands/datasource-capability-upsert-cli.js +112 -0
  24. package/lib/commands/datasource-capability.js +4 -2
  25. package/lib/commands/env-secret-context.js +113 -0
  26. package/lib/commands/env-secret-list.js +200 -0
  27. package/lib/commands/env-secret-push-confirm.js +54 -0
  28. package/lib/commands/env-secret-push-run.js +227 -0
  29. package/lib/commands/env-secret-push.js +168 -0
  30. package/lib/commands/repair-datasource-apply.js +2 -0
  31. package/lib/commands/repair-datasource-keyed-document.js +122 -0
  32. package/lib/commands/repair-datasource-run.js +1 -0
  33. package/lib/commands/setup-modes.js +1 -8
  34. package/lib/commands/setup-prompts.js +2 -180
  35. package/lib/commands/verify-operations-skip-e2e.js +25 -1
  36. package/lib/commands/verify-operations-steps.js +13 -1
  37. package/lib/commands/wizard-config-normalizer.js +7 -4
  38. package/lib/commands/wizard-core.js +5 -157
  39. package/lib/commands/wizard-file-saving.js +163 -0
  40. package/lib/core/env-platform-expand.js +5 -1
  41. package/lib/core/secrets-env-content.js +5 -1
  42. package/lib/core/secrets-env-write.js +10 -3
  43. package/lib/core/secrets-load.js +4 -2
  44. package/lib/datasource/binary-documents-validator.js +190 -0
  45. package/lib/datasource/capability/run-capability-upsert.js +202 -0
  46. package/lib/datasource/capability/upsert-ingredients.js +291 -0
  47. package/lib/datasource/capability/upsert-operations.js +138 -0
  48. package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
  49. package/lib/datasource/validate.js +12 -5
  50. package/lib/generator/index.js +3 -0
  51. package/lib/lifecycle/product-model.js +4 -3
  52. package/lib/lifecycle/report-display.js +3 -2
  53. package/lib/parameters/infra-parameter-catalog.js +1 -1
  54. package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
  55. package/lib/programmatic/builder-help-governance.js +1 -1
  56. package/lib/programmatic/builder-help.js +1 -1
  57. package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
  58. package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
  59. package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
  60. package/lib/role-assistant/test-runner-workhub.js +28 -19
  61. package/lib/schema/application-schema.json +14 -2
  62. package/lib/schema/external-datasource.schema.json +23 -3
  63. package/lib/schema/infra.parameter.yaml +388 -38
  64. package/lib/utils/compose-generate-docker-compose.js +7 -12
  65. package/lib/utils/datasource-binary-evidence.js +92 -0
  66. package/lib/utils/datasource-test-run-capability-scope.js +44 -1
  67. package/lib/utils/datasource-test-run-debug-display.js +2 -0
  68. package/lib/utils/datasource-test-run-display.js +8 -2
  69. package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
  70. package/lib/utils/datasource-test-run-tty-log.js +2 -0
  71. package/lib/utils/env-copy.js +11 -10
  72. package/lib/utils/external-system-system-test-tty.js +3 -2
  73. package/lib/utils/image-tags.js +2 -2
  74. package/lib/utils/platform-kv-ref.js +1 -1
  75. package/lib/utils/prepare-local-data-mount.js +58 -0
  76. package/lib/utils/secrets-helpers.js +0 -1
  77. package/lib/utils/system-secret-mapping.js +125 -0
  78. package/lib/utils/test-log-writer.js +2 -1
  79. package/lib/validation/external-manifest-validator.js +5 -0
  80. package/lib/validation/openapi-contract-surface-validator.js +3 -1
  81. package/lib/validation/validate-external-file.js +5 -1
  82. package/package.json +4 -3
  83. package/templates/agent-kit/agent-kit.yaml +1 -1
  84. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
  85. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
  86. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -2
  87. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +6 -2
  88. package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
  89. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +7 -1
  90. package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
  91. package/templates/agent-kit/skills/shared/interaction.md +83 -0
  92. package/templates/agent-kit/skills/shared/status.md +3 -12
  93. package/templates/applications/builder-api/application.yaml +1 -1
  94. package/templates/applications/builder-api/env.template +5 -1
  95. package/templates/applications/dataplane/application.yaml +1 -1
  96. package/templates/applications/dataplane/env.template +10 -12
  97. package/templates/applications/keycloak/application.yaml +6 -1
  98. package/templates/applications/miso-controller/application.yaml +74 -2
  99. package/templates/applications/miso-controller/env.template +42 -3
  100. package/templates/applications/miso-controller/rbac.yaml +17 -0
  101. package/templates/external-system/external-datasource.yaml.hbs +7 -1
  102. package/templates/marketplace/main.json +4 -4
  103. package/templates/python/docker-compose.hbs +1 -1
  104. package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -41
  105. /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
  106. /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
  107. /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
  108. /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
  109. /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
  110. /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
  111. /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
  112. /package/{lib/programmatic/help-content → docs/builder-help/content}/workflow.md +0 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Build a no-duplicate upsert proof from author-owned scenario facts.
3
+ *
4
+ * @fileoverview Safe testPayload scaffolding for datasource capability upsert
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { deepClone } = require('./copy-operations');
10
+
11
+ const RUN_TOKEN = /\{\{\s*testRunId\s*\}\}/i;
12
+
13
+ function scenarioInput(scenario) {
14
+ const input = scenario?.input;
15
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
16
+ const properties = input.properties;
17
+ return properties && typeof properties === 'object' && !Array.isArray(properties)
18
+ ? properties
19
+ : input;
20
+ }
21
+
22
+ function scalar(value) {
23
+ return value !== null && ['string', 'number', 'boolean'].includes(typeof value);
24
+ }
25
+
26
+ function identityFromInput(input, identityFields) {
27
+ if (!input) return null;
28
+ const entries = identityFields.map((field) => [field, input[field]]);
29
+ if (entries.some(([, value]) => !scalar(value) || value === '')) return null;
30
+ return Object.fromEntries(entries);
31
+ }
32
+
33
+ function sameIdentity(first, second, identityFields) {
34
+ return identityFields.every((field) => first[field] === second[field]);
35
+ }
36
+
37
+ function isCollisionSafe(identity) {
38
+ return Object.values(identity).some((value) =>
39
+ typeof value === 'string' && RUN_TOKEN.test(value)
40
+ );
41
+ }
42
+
43
+ function profileFields(doc, targetKey) {
44
+ const profile = doc.exposed?.profiles?.[targetKey];
45
+ if (Array.isArray(profile)) return profile;
46
+ return Array.isArray(profile?.attributes) ? profile.attributes : [];
47
+ }
48
+
49
+ function changedPublishedField(doc, targetKey, first, second, identityFields) {
50
+ const identity = new Set(identityFields);
51
+ const published = new Set(profileFields(doc, targetKey));
52
+ return Object.keys(first).find((field) =>
53
+ !identity.has(field) && published.has(field) && scalar(first[field]) &&
54
+ scalar(second[field]) && first[field] !== second[field]
55
+ ) || null;
56
+ }
57
+
58
+ function analyzeWritePair(doc, targetKey, identityFields, firstRow, secondRow) {
59
+ const first = scenarioInput(firstRow);
60
+ const second = scenarioInput(secondRow);
61
+ const firstIdentity = identityFromInput(first, identityFields);
62
+ const secondIdentity = identityFromInput(second, identityFields);
63
+ if (!firstIdentity || !secondIdentity) return { reason: 'both writes must contain every business identity field' };
64
+ if (!sameIdentity(firstIdentity, secondIdentity, identityFields)) {
65
+ return { reason: 'the two writes must use the same business identity values' };
66
+ }
67
+ if (!isCollisionSafe(firstIdentity)) {
68
+ return { reason: 'the business identity must include {{testRunId}} to isolate each live run' };
69
+ }
70
+ const changedField = changedPublishedField(
71
+ doc, targetKey, first, second, identityFields
72
+ );
73
+ if (!changedField) {
74
+ return { reason: 'the second write must change one published non-identity scalar field' };
75
+ }
76
+ return { first, second, identity: firstIdentity, changedField };
77
+ }
78
+
79
+ function exactLookup(operation, input, expectedRows, identityKey) {
80
+ return {
81
+ operation,
82
+ input: deepClone(input),
83
+ identityKey,
84
+ matchMode: 'exactIdentities',
85
+ expectedRows: deepClone(expectedRows)
86
+ };
87
+ }
88
+
89
+ function expectedRow(identity, changedField, value) {
90
+ return { ...deepClone(identity), [changedField]: value };
91
+ }
92
+
93
+ function hasExactProof(scenarios, index, targetKey, lookupKey, facts) {
94
+ const rows = scenarios.slice(index, index + 5);
95
+ if (rows.length !== 5) return false;
96
+ const operations = rows.map((row) => row?.operation);
97
+ if (operations.join('|') !== [lookupKey, targetKey, lookupKey, targetKey, lookupKey].join('|')) {
98
+ return false;
99
+ }
100
+ const [before, , afterFirst, , afterSecond] = rows;
101
+ return [before, afterFirst, afterSecond].every((row) =>
102
+ row?.matchMode === 'exactIdentities' && row?.identityKey === facts.changedField
103
+ ) && Array.isArray(before.expectedRows) && before.expectedRows.length === 0 &&
104
+ afterFirst.expectedRows?.length === 1 && afterSecond.expectedRows?.length === 1 &&
105
+ afterFirst.expectedRows?.[0]?.[facts.changedField] === facts.first[facts.changedField] &&
106
+ afterSecond.expectedRows?.[0]?.[facts.changedField] === facts.second[facts.changedField];
107
+ }
108
+
109
+ function findExistingProof(doc, scenarios, targetKey, lookupKey, identityFields) {
110
+ for (let index = 0; index <= scenarios.length - 5; index += 1) {
111
+ const facts = analyzeWritePair(
112
+ doc, targetKey, identityFields, scenarios[index + 1], scenarios[index + 3]
113
+ );
114
+ if (!facts.reason && hasExactProof(scenarios, index, targetKey, lookupKey, facts)) return true;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ function findAdjacentWrites(doc, scenarios, targetKey, identityFields) {
120
+ let lastReason = 'two adjacent enabled upsert scenarios are required';
121
+ for (let index = 0; index < scenarios.length - 1; index += 1) {
122
+ const first = scenarios[index];
123
+ const second = scenarios[index + 1];
124
+ if (first?.operation !== targetKey || second?.operation !== targetKey) continue;
125
+ if (first.enabled === false || second.enabled === false) continue;
126
+ const facts = analyzeWritePair(doc, targetKey, identityFields, first, second);
127
+ if (!facts.reason) return { index, facts };
128
+ lastReason = facts.reason;
129
+ }
130
+ return { reason: lastReason };
131
+ }
132
+
133
+ function insufficientIssue(reason) {
134
+ return {
135
+ code: 'UPSERT-TEST-DATA-INSUFFICIENT',
136
+ severity: 'blocking',
137
+ ownership: 'test-payload',
138
+ aiFixable: false,
139
+ message: `Safe --test scaffolding was not generated: ${reason}; no values were fabricated`,
140
+ consequence: 'A weaker generated case could provide false no-duplicate confidence.',
141
+ remediation: ['Author two adjacent upsert inputs with the same isolated identity and one changed published field, then rerun --test.'],
142
+ prevention: 'Keep collision-safe two-write facts with the datasource test payload.',
143
+ residualRisk: 'Upsert remains unproven until the exact live chain passes.'
144
+ };
145
+ }
146
+
147
+ function buildProofRows(lookupKey, facts) {
148
+ const firstExpected = expectedRow(
149
+ facts.identity, facts.changedField, facts.first[facts.changedField]
150
+ );
151
+ const secondExpected = expectedRow(
152
+ facts.identity, facts.changedField, facts.second[facts.changedField]
153
+ );
154
+ return [
155
+ exactLookup(lookupKey, facts.identity, [], facts.changedField),
156
+ exactLookup(lookupKey, facts.identity, [firstExpected], facts.changedField),
157
+ exactLookup(lookupKey, facts.identity, [secondExpected], facts.changedField)
158
+ ];
159
+ }
160
+
161
+ function scaffoldUpsertTests(originalDoc, analysis, targetKey = 'upsert') {
162
+ const scenarios = originalDoc.testPayload?.scenarios;
163
+ const lookupKey = analysis?.selectedLookup?.operationKey;
164
+ const identities = Array.isArray(analysis?.identityFields) ? analysis.identityFields : [];
165
+ if (!Array.isArray(scenarios) || !lookupKey || identities.length === 0) {
166
+ return { changed: false, issue: insufficientIssue('required scenarios or exact lookup facts are missing') };
167
+ }
168
+ if (findExistingProof(originalDoc, scenarios, targetKey, lookupKey, identities)) {
169
+ return { changed: false, doc: originalDoc, patchOperations: [], updatedSections: [] };
170
+ }
171
+ const pair = findAdjacentWrites(originalDoc, scenarios, targetKey, identities);
172
+ if (pair.reason) return { changed: false, issue: insufficientIssue(pair.reason) };
173
+ const [before, afterFirst, afterSecond] = buildProofRows(lookupKey, pair.facts);
174
+ const doc = deepClone(originalDoc);
175
+ doc.testPayload.scenarios.splice(pair.index, 2,
176
+ before, scenarios[pair.index], afterFirst, scenarios[pair.index + 1], afterSecond);
177
+ return {
178
+ changed: true,
179
+ doc,
180
+ patchOperations: [{ op: 'replace', path: '/testPayload/scenarios', value: doc.testPayload.scenarios }],
181
+ updatedSections: ['testPayload.scenarios: upsert no-duplicate proof']
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ scaffoldUpsertTests,
187
+ analyzeWritePair,
188
+ insufficientIssue
189
+ };
@@ -23,6 +23,8 @@ const { validateFieldReferences } = require('./field-reference-validator');
23
23
  const { validateAbac } = require('./abac-validator');
24
24
  const { buildDatasourceValidateSummary } = require('./datasource-validate-summary');
25
25
 
26
+ const { validateBinaryDocumentsContract } = require('./binary-documents-validator');
27
+
26
28
  const DATASOURCE_CONFIG_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
27
29
  const EXCLUDE_CONFIG_NAMES = new Set([
28
30
  'application.json',
@@ -242,12 +244,14 @@ async function validateDatasourceFile(filePathOrKey) {
242
244
  };
243
245
  }
244
246
 
247
+ const binary = validateBinaryDocumentsContract(parsed);
248
+ const warnings = binary.warnings.map(issue => `${issue.code}: ${issue.message}`);
245
249
  const postSchemaErrors = collectPostSchemaErrors(parsed);
246
250
  if (postSchemaErrors.length > 0) {
247
251
  return {
248
252
  valid: false,
249
253
  errors: postSchemaErrors,
250
- warnings: [],
254
+ warnings,
251
255
  resolvedPath: filePath,
252
256
  summary
253
257
  };
@@ -256,7 +260,7 @@ async function validateDatasourceFile(filePathOrKey) {
256
260
  return {
257
261
  valid: true,
258
262
  errors: [],
259
- warnings: [],
263
+ warnings,
260
264
  resolvedPath: filePath,
261
265
  summary
262
266
  };
@@ -269,7 +273,8 @@ async function validateDatasourceFile(filePathOrKey) {
269
273
  function collectPostSchemaErrors(parsed) {
270
274
  return [
271
275
  ...validateFieldReferences(parsed),
272
- ...validateAbac(parsed)
276
+ ...validateAbac(parsed),
277
+ ...validateBinaryDocumentsContract(parsed).errors.map(issue => `${issue.code}: ${issue.message}`)
273
278
  ];
274
279
  }
275
280
 
@@ -303,12 +308,14 @@ function validateDatasourceParsed(parsed) {
303
308
  };
304
309
  }
305
310
 
311
+ const binary = validateBinaryDocumentsContract(parsed);
312
+ const warnings = binary.warnings.map(issue => `${issue.code}: ${issue.message}`);
306
313
  const postSchemaErrors = collectPostSchemaErrors(parsed);
307
314
  if (postSchemaErrors.length > 0) {
308
315
  return {
309
316
  valid: false,
310
317
  errors: postSchemaErrors,
311
- warnings: [],
318
+ warnings,
312
319
  summary
313
320
  };
314
321
  }
@@ -316,7 +323,7 @@ function validateDatasourceParsed(parsed) {
316
323
  return {
317
324
  valid: true,
318
325
  errors: [],
319
- warnings: [],
326
+ warnings,
320
327
  summary
321
328
  };
322
329
  }
@@ -22,6 +22,7 @@ const { generateControllerManifest } = require('./external-controller-manifest')
22
22
  const { resolveVersionForApp } = require('../utils/image-version');
23
23
  const { getContainerPort } = require('../utils/port-resolver');
24
24
  const { buildEnvVarMap } = require('../utils/env-map');
25
+ const { mergeAdditionalPublishedPorts } = require('../utils/docker-manifest-public-port');
25
26
 
26
27
  /**
27
28
  * Generates deployment JSON from application configuration files
@@ -247,6 +248,7 @@ async function buildDeploymentManifestInMemory(appName, options = {}) {
247
248
  ? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
248
249
  }
249
250
  const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
251
+ await mergeAdditionalPublishedPorts(envVarMap, variablesWithVersion);
250
252
  substituteEnvVarsInDeployment(deployment, envVarMap);
251
253
  _validator.validateNoUnresolvedVariablesInDeployment(deployment);
252
254
  return { deployment, appPath };
@@ -300,6 +302,7 @@ async function writeRegularDeployJson(appName, appPath, appType, options = {}) {
300
302
  };
301
303
  const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
302
304
  const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
305
+ await mergeAdditionalPublishedPorts(envVarMap, variablesWithVersion);
303
306
  const deployment = buildAndValidateDeployment(appName, variablesWithVersion, envTemplate, rbac, {
304
307
  envVarMap,
305
308
  target: options.target
@@ -134,6 +134,7 @@ function computeOperationalReadinessPercent(checks) {
134
134
  let earned = 0;
135
135
  let total = 0;
136
136
  for (const [key, weight] of Object.entries(OPERATIONS_WEIGHTS)) {
137
+ if (checks[key] === null) continue;
137
138
  total += weight;
138
139
  if (checks[key] === true) {
139
140
  earned += weight;
@@ -159,9 +160,9 @@ function buildOperationsChecksFromSteps(steps) {
159
160
  authorization: integration || e2e,
160
161
  openApi: validation,
161
162
  mappings: validation,
162
- sync: e2e,
163
- crud: e2e,
164
- execution: e2e,
163
+ sync: steps.keyedDocumentGet === true ? null : e2e,
164
+ crud: steps.keyedDocumentGet === true ? steps.documentPersistence === true : e2e,
165
+ execution: steps.keyedDocumentGet === true ? steps.documentPersistence === true : e2e,
165
166
  validation,
166
167
  unitTests: unit,
167
168
  integrationTests: integration,
@@ -132,8 +132,9 @@ function displayOperationsVerificationSteps(detailsGrouped) {
132
132
  for (const [groupName, keys] of groups) {
133
133
  logger.log(chalk.white(groupName));
134
134
  for (const key of keys) {
135
- const passed = detailsGrouped[groupName.toLowerCase()]?.[key] ?? detailsGrouped[key];
136
- const glyph = passed ? successGlyph() : failureGlyph();
135
+ const group = detailsGrouped[groupName.toLowerCase()];
136
+ const passed = group && Object.hasOwn(group, key) ? group[key] : detailsGrouped[key];
137
+ const glyph = passed === null ? chalk.gray('⏭ Not applicable') : passed ? successGlyph() : failureGlyph();
137
138
  logger.log(` ${labels[key] || key}`.padEnd(24) + glyph);
138
139
  }
139
140
  }
@@ -135,7 +135,7 @@ function createCatalogApi(doc, exact, patterns) {
135
135
  const entry = findEntryForKey(key);
136
136
  const gen = entry && entry.generator;
137
137
  // emptyAllowed: e.g. Redis password when no requirepass.
138
- // emptyString: optional user-supplied keys (OpenAI / Azure OpenAI) — absent resolves like ''.
138
+ // emptyString: optional user-supplied catalog keys — absent resolves like ''.
139
139
  return Boolean(gen && (gen.type === 'emptyAllowed' || gen.type === 'emptyString'));
140
140
  }
141
141
 
@@ -10,7 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { buildHelpEnvelope } = require('./builder-help-envelope');
12
12
 
13
- const HELP_MD_DIR = path.join(__dirname, 'help-content');
13
+ const HELP_MD_DIR = path.join(__dirname, '..', '..', 'docs', 'builder-help', 'content');
14
14
 
15
15
  /** @type {ReadonlyArray<string>} */
16
16
  const REPAIR_COMMANDS = Object.freeze([
@@ -15,7 +15,7 @@ const { listPlatformRoles } = require('./platform-rbac-catalog');
15
15
  const { CHANNEL_TARGET_KEYS, CHANNEL_TARGETS } = require('../channels/channel-targets');
16
16
 
17
17
  const BUILDER_HELP_DIR = path.join(__dirname, '..', '..', 'docs', 'builder-help');
18
- const HELP_MD_DIR = path.join(__dirname, 'help-content');
18
+ const HELP_MD_DIR = path.join(BUILDER_HELP_DIR, 'content');
19
19
 
20
20
  /**
21
21
  * @param {string} fileName
@@ -65,7 +65,7 @@ const PROSE_TOPIC_FILES = Object.freeze({
65
65
  enterpriseSyncFabrix: 'enterprise-sync-fabrix.md'
66
66
  });
67
67
 
68
- const HELP_MD_DIR = path.join(__dirname, 'help-content');
68
+ const HELP_MD_DIR = path.join(__dirname, '..', '..', 'docs', 'builder-help', 'content');
69
69
 
70
70
  /**
71
71
  * @param {string} message
@@ -5,6 +5,8 @@
5
5
 
6
6
  'use strict';
7
7
 
8
+ const { coversMissingFields } = require('./test-runner-workhub-missing-fields');
9
+
8
10
  const {
9
11
  resolveTargetSystemKey,
10
12
  shouldStampSuiteBid,
@@ -409,7 +411,8 @@ function resolveRespondValue(testCase, wait, statusRow, objective) {
409
411
  if (fromCase !== undefined) {
410
412
  return fromCase;
411
413
  }
412
- return synthesizeWaitAnswer(wait, statusRow, objective, testCase);
414
+ const synthesized = synthesizeWaitAnswer(wait, statusRow, objective, testCase);
415
+ return coversMissingFields(wait, synthesized) ? synthesized : undefined;
413
416
  }
414
417
 
415
418
  function waitRespondFingerprint(wait) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @fileoverview Missing-field coverage for synthesized Work Hub answers.
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ function asPlainObject(value) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
9
+ return {};
10
+ }
11
+ return value;
12
+ }
13
+
14
+ /**
15
+ * Fields the runtime named as missing (missingRequestBody waits). A synthesized
16
+ * answer that lacks any of them only makes the runtime ask the same question
17
+ * again, so it must not be sent.
18
+ * @param {Object} wait
19
+ * @returns {string[]}
20
+ */
21
+ function waitMissingFields(wait) {
22
+ const raw = wait?.objectRef && typeof wait.objectRef === 'object' ? wait.objectRef.missingFields : null;
23
+ const list = Array.isArray(raw) ? raw : String(raw || '').split(',');
24
+ return list.map((row) => String(row || '').trim()).filter(Boolean);
25
+ }
26
+
27
+ function coversMissingFields(wait, value) {
28
+ if (value === undefined) {
29
+ return false;
30
+ }
31
+ const missing = waitMissingFields(wait);
32
+ if (missing.length === 0) {
33
+ return true;
34
+ }
35
+ const obj = asPlainObject(value);
36
+ if (!obj) {
37
+ return false;
38
+ }
39
+ return missing.every((field) => obj[field] !== undefined && obj[field] !== null && obj[field] !== '');
40
+ }
41
+
42
+ module.exports = { coversMissingFields, waitMissingFields };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @fileoverview Work Hub poll stop rules for waits the runner cannot resolve.
3
+ *
4
+ * A wait with no matching case answer, a question the runtime re-issues
5
+ * unchanged after our answer, or a failed respond call never resolves by
6
+ * polling. Stopping at once keeps a stalled case at seconds, not the 180 s
7
+ * poll timeout (dev01 sales ladder, 2026-09-26: six cases, 18 min waiting).
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const UNANSWERED_WAIT_TYPES = new Set(['respond-skipped', 'respond-repeated', 'respond-error']);
13
+
14
+ /**
15
+ * The runtime asked the same question again after our answer: the answer did
16
+ * not resolve it, and repeating it cannot. Record once so the poll can stop.
17
+ */
18
+ function recordRepeatedResponse(interactions, peId, fingerprint, wait) {
19
+ const already = interactions.some(
20
+ (row) => row && row.type === 'respond-repeated' && row.fingerprint === fingerprint
21
+ );
22
+ if (already) {
23
+ return;
24
+ }
25
+ interactions.push({
26
+ type: 'respond-repeated',
27
+ processExecutionId: peId,
28
+ fingerprint,
29
+ questionKey: wait.questionKey || wait.requestKey || null,
30
+ objectRefKind: wait.objectRef?.kind || null,
31
+ reason: 'same-question-after-answer'
32
+ });
33
+ }
34
+
35
+ function recordRespondError(interactions, peId, error) {
36
+ interactions.push({
37
+ type: 'respond-error',
38
+ processExecutionId: peId,
39
+ error: (error && error.message) || String(error)
40
+ });
41
+ }
42
+
43
+ /**
44
+ * A wait we could not answer (no matching case answer, the runtime re-asked
45
+ * after our answer, or respond failed) will not change by polling: stop now
46
+ * instead of waiting for the poll timeout.
47
+ * @param {Array<Object>} interactions
48
+ * @returns {string|null} stop reason or null to keep polling
49
+ */
50
+ function unansweredWaitStopReason(interactions) {
51
+ const last = interactions.length > 0 ? interactions[interactions.length - 1] : null;
52
+ if (!last || !UNANSWERED_WAIT_TYPES.has(last.type)) {
53
+ return null;
54
+ }
55
+ return last.type === 'respond-error' ? 'respondFailed' : 'unansweredQuestion';
56
+ }
57
+
58
+ function recordSkippedResponse(interactions, peId, fingerprint, wait) {
59
+ interactions.push({
60
+ type: 'respond-skipped',
61
+ processExecutionId: peId,
62
+ fingerprint,
63
+ questionKey: wait.questionKey || wait.requestKey || null,
64
+ objectRefKind: wait.objectRef?.kind || null,
65
+ reason: 'no-matching-answer-or-synthesis'
66
+ });
67
+ }
68
+
69
+ const ALREADY_TERMINAL_RE = /already terminal/i;
70
+
71
+ /**
72
+ * work.respond resumes the Decision Engine inline; by the time work.continue
73
+ * lands the execution may already be terminal. That is a finished run, not a
74
+ * failed answer.
75
+ */
76
+ async function continueAfterRespond(dataplaneUrl, authConfig, peId) {
77
+ const { continueWorkExecution } = require('../api/work.api');
78
+ try {
79
+ await continueWorkExecution(dataplaneUrl, authConfig, peId, {});
80
+ } catch (err) {
81
+ if (!ALREADY_TERMINAL_RE.test((err && err.message) || String(err))) {
82
+ throw err;
83
+ }
84
+ }
85
+ }
86
+
87
+ module.exports = {
88
+ continueAfterRespond,
89
+ UNANSWERED_WAIT_TYPES,
90
+ recordSkippedResponse,
91
+ recordRepeatedResponse,
92
+ recordRespondError,
93
+ unansweredWaitStopReason
94
+ };
@@ -13,8 +13,7 @@ const {
13
13
  getWorkTask,
14
14
  approveWorkTask,
15
15
  getWorkExecutionStatus,
16
- respondWorkExecution,
17
- continueWorkExecution
16
+ respondWorkExecution
18
17
  } = require('../api/work.api');
19
18
  const {
20
19
  synthesizeWaitAnswer,
@@ -29,6 +28,13 @@ const {
29
28
  persistCaseOpenApiViaBuilderApi
30
29
  } = require('./test-runner-bid-apply');
31
30
  const { resolveTargetSystemKey } = require('./test-runner-bid-document');
31
+ const {
32
+ recordSkippedResponse,
33
+ recordRepeatedResponse,
34
+ recordRespondError,
35
+ unansweredWaitStopReason,
36
+ continueAfterRespond
37
+ } = require('./test-runner-workhub-wait-stop');
32
38
  const {
33
39
  TERMINAL_TASK,
34
40
  delayMs,
@@ -178,17 +184,6 @@ async function maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx) {
178
184
  await _attemptApproveTask(dataplaneUrl, authConfig, taskId, ctx);
179
185
  }
180
186
 
181
- function recordSkippedResponse(interactions, peId, fingerprint, wait) {
182
- interactions.push({
183
- type: 'respond-skipped',
184
- processExecutionId: peId,
185
- fingerprint,
186
- questionKey: wait.questionKey || wait.requestKey || null,
187
- objectRefKind: wait.objectRef?.kind || null,
188
- reason: 'no-matching-answer-or-synthesis'
189
- });
190
- }
191
-
192
187
  function recordBidEmit(testCase, interactions, peId) {
193
188
  const { loadCaseBidDocument } = require('./test-runner-workhub-answers');
194
189
  const emittedBid = loadCaseBidDocument(testCase);
@@ -237,6 +232,7 @@ async function respondToWaitingExecution(
237
232
  const wait = statusRow.wait && typeof statusRow.wait === 'object' ? statusRow.wait : {};
238
233
  const fingerprint = waitRespondFingerprint(wait);
239
234
  if (alreadyResponded(interactions, fingerprint)) {
235
+ recordRepeatedResponse(interactions, peId, fingerprint, wait);
240
236
  return;
241
237
  }
242
238
  const payload = buildRespondPayload(
@@ -260,7 +256,7 @@ async function respondToWaitingExecution(
260
256
  }
261
257
  }
262
258
  await respondWorkExecution(dataplaneUrl, authConfig, peId, payload);
263
- await continueWorkExecution(dataplaneUrl, authConfig, peId, {});
259
+ await continueAfterRespond(dataplaneUrl, authConfig, peId);
264
260
  interactions.push({ type: 'respond', processExecutionId: peId, fingerprint });
265
261
  if (shouldApplyBidAfterWait(testCase, wait)) {
266
262
  recordBidEmit(testCase, interactions, peId);
@@ -291,8 +287,9 @@ async function maybeRespondWaiting(dataplaneUrl, authConfig, taskRow, testCase,
291
287
  interactions,
292
288
  statusRow
293
289
  );
294
- } catch (_err) {
295
- // Soft: continue polling task.
290
+ } catch (err) {
291
+ // A failed respond/continue never resolves itself; record it so the poll stops.
292
+ recordRespondError(interactions, peId, err);
296
293
  }
297
294
  }
298
295
 
@@ -301,7 +298,13 @@ function assertTerminalWorkHubTask(last, taskId, timeoutMs, options = {}) {
301
298
  throw new Error(`Work Hub task ${taskId} produced no status rows.`);
302
299
  }
303
300
  const finalStatusToken = String(last?.status || last?.taskStatus || '').trim().toLowerCase();
304
- if (!TERMINAL_TASK.has(finalStatusToken) && options.recordTimeout === true) {
301
+ if (TERMINAL_TASK.has(finalStatusToken)) {
302
+ return last;
303
+ }
304
+ if (options.stoppedReason) {
305
+ return { ...last, pollStopped: options.stoppedReason, lastStatus: finalStatusToken || 'unknown' };
306
+ }
307
+ if (options.recordTimeout === true) {
305
308
  last = {
306
309
  ...last,
307
310
  pollTimedOut: true,
@@ -353,14 +356,18 @@ async function pollWorkHubTask(dataplaneUrl, authConfig, taskId, testCase, optio
353
356
  applyWorkHubPollStatus(ctx, last, status);
354
357
  await maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx);
355
358
  await maybeRespondWaiting(dataplaneUrl, authConfig, last, testCase, ctx.interactions);
356
- if (shouldStopPendingApprovalPoll(status, ctx)) {
359
+ ctx.stoppedReason = unansweredWaitStopReason(ctx.interactions);
360
+ if (ctx.stoppedReason || shouldStopPendingApprovalPoll(status, ctx)) {
357
361
  break;
358
362
  }
359
363
  await delayMs(intervalMs);
360
364
  }
361
365
 
362
366
  return {
363
- finalTask: assertTerminalWorkHubTask(last, taskId, timeoutMs, { recordTimeout: true }),
367
+ finalTask: assertTerminalWorkHubTask(last, taskId, timeoutMs, {
368
+ recordTimeout: !ctx.stoppedReason,
369
+ stoppedReason: ctx.stoppedReason || null
370
+ }),
364
371
  timeline,
365
372
  interactions: ctx.interactions
366
373
  };
@@ -478,6 +485,8 @@ async function runWorkHubCaseLifecycle(roleKey, testCase, runtimeContext, option
478
485
  module.exports = {
479
486
  wantsWorkHubPath,
480
487
  runWorkHubCaseLifecycle,
488
+ pollWorkHubTask,
489
+ unansweredWaitStopReason,
481
490
  planAlreadyActivated,
482
491
  isPlanLostError,
483
492
  extractPeFromTask,
@@ -7,12 +7,12 @@
7
7
  "key": "application-schema",
8
8
  "name": "Application Configuration Schema",
9
9
  "description": "JSON schema for validating AI Fabrix application configuration files",
10
- "version": "1.9.0",
10
+ "version": "1.10.0",
11
11
  "type": "schema",
12
12
  "category": "infrastructure",
13
13
  "author": "AI Fabrix Team",
14
14
  "createdAt": "2024-01-01T00:00:00Z",
15
- "updatedAt": "2026-09-23T00:00:00Z",
15
+ "updatedAt": "2026-09-25T00:00:00Z",
16
16
  "compatibility": {
17
17
  "minVersion": "1.0.0",
18
18
  "maxVersion": "2.0.0",
@@ -30,6 +30,14 @@
30
30
  "external-datasource.schema.json"
31
31
  ],
32
32
  "changelog": [
33
+ {
34
+ "version": "1.10.0",
35
+ "date": "2026-09-25T00:00:00Z",
36
+ "changes": [
37
+ "Added optional top-level webSocketsEnabled: request Azure App Service WebSocket support for applications whose manifest opts in. Absent or false preserves existing deployment behavior."
38
+ ],
39
+ "breaking": false
40
+ },
33
41
  {
34
42
  "version": "1.9.0",
35
43
  "date": "2026-09-23T00:00:00Z",
@@ -231,6 +239,10 @@
231
239
  "type": "boolean",
232
240
  "description": "Whether application requires storage.Physical storage is mapped to /mnt/data/. Blob storage is mapped to SMB File share /data/{app-key}/."
233
241
  },
242
+ "webSocketsEnabled": {
243
+ "type": "boolean",
244
+ "description": "Whether the hosting provider must enable WebSocket upgrades for this application. Optional and false by default."
245
+ },
234
246
  "platforms": {
235
247
  "type": "array",
236
248
  "description": "Platform resolution: the target Azure resource group is matched against these entries, yielding {platform} and {platform.name}. Optional and independent of environments.",