@aifabrix/builder 2.60.0 → 2.62.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 (177) 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/applications.api.js +22 -2
  14. package/lib/api/bootstrap-snapshot.api.js +115 -0
  15. package/lib/api/configuration.api.js +131 -0
  16. package/lib/api/system-secrets.api.js +72 -0
  17. package/lib/app/deploy.js +14 -5
  18. package/lib/app/run-docker-fallback.js +6 -1
  19. package/lib/app/run-env-compose.js +5 -6
  20. package/lib/app/run-helpers.js +2 -1
  21. package/lib/app/run-parameter-sync.js +142 -0
  22. package/lib/app/run.js +6 -4
  23. package/lib/build/docker-build-args.js +5 -2
  24. package/lib/build/index.js +3 -2
  25. package/lib/build/standard-docker-build.js +6 -2
  26. package/lib/channels/channel-artifact-generator.js +13 -3
  27. package/lib/cli/infra-guided-footers.js +10 -8
  28. package/lib/cli/infra-guided.js +30 -10
  29. package/lib/cli/setup-app.js +5 -0
  30. package/lib/cli/setup-environment.js +156 -0
  31. package/lib/cli/setup-infra-up-platform-action.js +14 -1
  32. package/lib/cli/setup-platform.js +2 -0
  33. package/lib/commands/auth-config.js +2 -0
  34. package/lib/commands/datasource-capability-upsert-cli.js +112 -0
  35. package/lib/commands/datasource-capability.js +4 -2
  36. package/lib/commands/env-secret-context.js +113 -0
  37. package/lib/commands/env-secret-list.js +200 -0
  38. package/lib/commands/env-secret-push-confirm.js +54 -0
  39. package/lib/commands/env-secret-push-run.js +227 -0
  40. package/lib/commands/env-secret-push.js +168 -0
  41. package/lib/commands/login.js +4 -0
  42. package/lib/commands/repair-datasource-apply.js +5 -1
  43. package/lib/commands/repair-datasource-keyed-document.js +122 -0
  44. package/lib/commands/repair-datasource-legacy.js +94 -0
  45. package/lib/commands/repair-datasource-run.js +1 -0
  46. package/lib/commands/setup-image-refresh.js +26 -0
  47. package/lib/commands/setup-modes.js +8 -9
  48. package/lib/commands/setup-prompts-platform-mode.js +34 -0
  49. package/lib/commands/setup-prompts.js +2 -180
  50. package/lib/commands/setup.js +3 -0
  51. package/lib/commands/up-builder-api.js +24 -8
  52. package/lib/commands/up-common.js +16 -1
  53. package/lib/commands/up-dataplane-bootstrap.js +70 -14
  54. package/lib/commands/up-dataplane-credentials.js +21 -0
  55. package/lib/commands/up-dataplane.js +37 -18
  56. package/lib/commands/up-integration-server.js +112 -0
  57. package/lib/commands/verify-operations-skip-e2e.js +25 -1
  58. package/lib/commands/verify-operations-steps.js +13 -1
  59. package/lib/commands/wizard-config-normalizer.js +12 -5
  60. package/lib/commands/wizard-core.js +5 -157
  61. package/lib/commands/wizard-file-saving.js +163 -0
  62. package/lib/core/admin-secrets-env-overlay.js +8 -3
  63. package/lib/core/env-platform-expand.js +5 -1
  64. package/lib/core/environment-access-policy.js +121 -0
  65. package/lib/core/environment-mode-policy.js +177 -0
  66. package/lib/core/local-env-overrides.js +149 -0
  67. package/lib/core/secrets-ensure.js +4 -2
  68. package/lib/core/secrets-env-content.js +17 -10
  69. package/lib/core/secrets-env-write.js +11 -4
  70. package/lib/core/secrets-load.js +4 -2
  71. package/lib/datasource/binary-documents-validator.js +190 -0
  72. package/lib/datasource/capability/capability-manifest.js +1 -0
  73. package/lib/datasource/capability/run-capability-upsert.js +202 -0
  74. package/lib/datasource/capability/upsert-ingredients.js +291 -0
  75. package/lib/datasource/capability/upsert-operations.js +138 -0
  76. package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
  77. package/lib/datasource/validate.js +12 -5
  78. package/lib/deployment/installation/index.js +14 -1
  79. package/lib/deployment/installation/infra-catalog.js +3 -0
  80. package/lib/deployment/installation/local-environment-stage.js +285 -0
  81. package/lib/deployment/installation/local-installation.js +76 -0
  82. package/lib/deployment/installation/stage-input.js +5 -1
  83. package/lib/generator/builders.js +3 -1
  84. package/lib/generator/index.js +3 -0
  85. package/lib/generator/split-variables.js +2 -2
  86. package/lib/lifecycle/product-model.js +4 -3
  87. package/lib/lifecycle/report-display.js +3 -2
  88. package/lib/parameters/database-adoption.js +125 -0
  89. package/lib/parameters/infra-parameter-catalog.js +1 -1
  90. package/lib/parameters/physical-database-naming.js +136 -0
  91. package/lib/parameters/physical-secret-name.js +194 -0
  92. package/lib/parameters/rsa-secret-values.js +165 -0
  93. package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
  94. package/lib/programmatic/builder-help-governance.js +1 -1
  95. package/lib/programmatic/builder-help.js +1 -1
  96. package/lib/programmatic/run-channel-install-artifacts.js +28 -2
  97. package/lib/programmatic/run-channel-package.js +3 -1
  98. package/lib/programmatic/workspace-context.js +37 -2
  99. package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
  100. package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
  101. package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
  102. package/lib/role-assistant/test-runner-workhub.js +28 -19
  103. package/lib/schema/application-schema.json +14 -2
  104. package/lib/schema/external-datasource.schema.json +23 -3
  105. package/lib/schema/infra.parameter.yaml +440 -56
  106. package/lib/schema/infrastructure-schema.json +213 -120
  107. package/lib/utils/compose-generate-docker-compose.js +9 -14
  108. package/lib/utils/compose-generator.js +4 -7
  109. package/lib/utils/compose-miso-env.js +2 -2
  110. package/lib/utils/compose-traefik-ingress-base.js +3 -4
  111. package/lib/utils/datasource-binary-evidence.js +92 -0
  112. package/lib/utils/datasource-test-run-capability-scope.js +44 -1
  113. package/lib/utils/datasource-test-run-debug-display.js +2 -0
  114. package/lib/utils/datasource-test-run-display.js +8 -2
  115. package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
  116. package/lib/utils/datasource-test-run-tty-log.js +2 -0
  117. package/lib/utils/declarative-url-ports.js +3 -0
  118. package/lib/utils/env-copy.js +11 -10
  119. package/lib/utils/env-environment-file-paths.js +105 -0
  120. package/lib/utils/environment-scoped-resources.js +48 -14
  121. package/lib/utils/external-system-system-test-tty.js +3 -2
  122. package/lib/utils/image-tags.js +2 -2
  123. package/lib/utils/paths-system-builder-keys.js +7 -1
  124. package/lib/utils/paths.js +7 -2
  125. package/lib/utils/platform-kv-ref.js +1 -1
  126. package/lib/utils/postgres-platform-bootstrap.js +35 -1
  127. package/lib/utils/prepare-local-data-mount.js +58 -0
  128. package/lib/utils/redis-env-scope.js +21 -1
  129. package/lib/utils/registry-auth-sources.js +154 -0
  130. package/lib/utils/registry-credentials.js +131 -14
  131. package/lib/utils/secrets-generator.js +30 -21
  132. package/lib/utils/secrets-helpers.js +5 -8
  133. package/lib/utils/secrets-kv-scope.js +127 -28
  134. package/lib/utils/secrets-materialize-local.js +9 -0
  135. package/lib/utils/secrets-missing-error.js +35 -1
  136. package/lib/utils/system-secret-mapping.js +125 -0
  137. package/lib/utils/test-log-writer.js +2 -1
  138. package/lib/utils/token-manager.js +14 -8
  139. package/lib/utils/url-public-path-prefix.js +6 -10
  140. package/lib/validation/external-manifest-validator.js +5 -0
  141. package/lib/validation/openapi-contract-surface-validator.js +3 -1
  142. package/lib/validation/validate-external-file.js +5 -1
  143. package/package.json +6 -4
  144. package/templates/agent-kit/agent-kit.yaml +1 -1
  145. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
  146. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
  147. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -2
  148. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +6 -2
  149. package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
  150. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +7 -1
  151. package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
  152. package/templates/agent-kit/skills/shared/interaction.md +83 -0
  153. package/templates/agent-kit/skills/shared/status.md +3 -12
  154. package/templates/applications/builder-api/application.yaml +1 -1
  155. package/templates/applications/builder-api/env.template +5 -1
  156. package/templates/applications/dataplane/application.yaml +2 -2
  157. package/templates/applications/dataplane/env.template +10 -12
  158. package/templates/applications/integration-server/application.yaml +91 -0
  159. package/templates/applications/integration-server/env.template +215 -0
  160. package/templates/applications/keycloak/application.yaml +6 -1
  161. package/templates/applications/mirrored-templates.json +52 -0
  162. package/templates/applications/miso-controller/application.yaml +116 -2
  163. package/templates/applications/miso-controller/env.template +62 -17
  164. package/templates/applications/miso-controller/rbac.yaml +17 -0
  165. package/templates/external-system/external-datasource.yaml.hbs +7 -1
  166. package/templates/marketplace/createUiDefinition.json +52 -4
  167. package/templates/marketplace/main.json +88 -10
  168. package/templates/python/docker-compose.hbs +1 -1
  169. package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -41
  170. /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
  171. /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
  172. /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
  173. /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
  174. /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
  175. /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
  176. /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
  177. /package/{lib/programmatic/help-content → docs/builder-help/content}/workflow.md +0 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @fileoverview Generate linked catalog RSA secrets without rotating existing identities.
3
+ */
4
+ const crypto = require('crypto');
5
+ const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
6
+
7
+ /** @param {*} value @returns {boolean} Whether a stored value is populated. */
8
+ function populated(value) {
9
+ return value !== undefined && value !== null && String(value).trim() !== '';
10
+ }
11
+
12
+ /**
13
+ * Read a stored key without exposing crypto errors or key material.
14
+ * @param {string} value
15
+ * @param {string} encryptionKey
16
+ * @param {string} pairId
17
+ * @returns {import('crypto').KeyObject}
18
+ */
19
+ function readPrivateKey(value, encryptionKey, pairId) {
20
+ try {
21
+ const pem = isEncrypted(value) ? decryptSecret(value, encryptionKey) : value;
22
+ const key = crypto.createPrivateKey(pem);
23
+ if (key.asymmetricKeyType !== 'rsa') throw new Error('Not RSA');
24
+ return key;
25
+ } catch {
26
+ throw new Error(`Cannot generate RSA pair "${pairId}": existing private key cannot be read as RSA. Restore a valid private key before retrying.`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Pairs whose private half belongs to Mori, not to an installation.
32
+ *
33
+ * Builder must never mint these. Licence trust is a two-signature chain, and both links
34
+ * exist so that substituting a key fails rather than silently succeeding:
35
+ *
36
+ * 1. Mori signs the licence. The installation verifies it against the public key held on
37
+ * disk, so a licence from anywhere else does not verify.
38
+ * 2. That on-disk material is itself validated against a key in Key Vault. The Key Vault is
39
+ * provisioned by the Marketplace deployment and readable only by Miso's managed
40
+ * identity — nobody can change it, including us — so swapping the on-disk public key for
41
+ * one whose private half an attacker holds fails at the second check.
42
+ *
43
+ * The second link is an **Azure** guarantee. A Docker installation has no Key Vault and no
44
+ * managed identity, so locally the chain is the pinned public key alone. Closing that is
45
+ * online validation against Mori, which is a known gap deferred on purpose: the delivery
46
+ * target is Azure, and Docker exists to validate the shape of that stack.
47
+ *
48
+ * It does not soften this rule. The one environment lacking the second check is the last
49
+ * one that should mint its own keys, and a locally generated pair would make local
50
+ * behaviour diverge from the Azure stack it exists to mirror.
51
+ *
52
+ * Generating the pair locally breaks both links at once: the installation would hold the
53
+ * private half of the key it verifies against, so it could sign a licence it then accepts,
54
+ * and the material would no longer correspond to what Key Vault validates. 227.1 pinned
55
+ * this key "precisely because a key travelling inside the token it verifies proves
56
+ * nothing"; a self-generated key proves exactly as little.
57
+ *
58
+ * The public half reaches an installation through the `moriLicensePublicKey` deployment
59
+ * parameter, which the installation contract already classifies as registration-supplied.
60
+ * An installation with neither half stays unpinned and refuses every licence, which is the
61
+ * documented and correct failure.
62
+ */
63
+ const ISSUER_SUPPLIED_PAIR_IDS = Object.freeze(['mori-license-signing', 'mori-policy-signing']);
64
+
65
+ /**
66
+ * @param {Object} entry - Catalog entry
67
+ * @returns {boolean} True when the pair is supplied by Mori rather than generated here
68
+ */
69
+ function isIssuerSuppliedPair(entry) {
70
+ return ISSUER_SUPPLIED_PAIR_IDS.includes(entry?.generator?.pairId);
71
+ }
72
+
73
+ /**
74
+ * Generate missing parts for one catalog pair, preserving existing key material.
75
+ * @param {Object[]} entries
76
+ * @param {Object} existing
77
+ * @param {string} encryptionKey
78
+ * @returns {Object} Missing values, including the linked counterpart when declared.
79
+ */
80
+ function generatePair(entries, existing, encryptionKey) {
81
+ const pairId = entries[0].generator.pairId;
82
+ const privateEntry = entries.find(e => e.generator.part === 'private');
83
+ const publicEntries = entries.filter(e => e.generator.part === 'public');
84
+ if (!privateEntry) throw new Error(`Cannot generate RSA pair "${pairId}": catalog has no private key entry.`);
85
+ const storedPrivate = existing[privateEntry.key];
86
+ const hasPublic = publicEntries.some(e => populated(existing[e.key]));
87
+ if (!populated(storedPrivate) && hasPublic) {
88
+ throw new Error(`Cannot generate RSA pair "${pairId}": public key exists but private key is missing. Restore the matching private key before retrying.`);
89
+ }
90
+ const privateKey = populated(storedPrivate)
91
+ ? readPrivateKey(storedPrivate, encryptionKey, pairId)
92
+ : crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
93
+ const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
94
+ const values = {};
95
+ if (!populated(storedPrivate)) {
96
+ values[privateEntry.key] = privateKey.export({ type: 'pkcs8', format: 'pem' });
97
+ }
98
+ for (const entry of publicEntries) {
99
+ if (!populated(existing[entry.key])) values[entry.key] = publicKey;
100
+ }
101
+ return values;
102
+ }
103
+
104
+ /**
105
+ * Prepare linked RSA values for a single write operation; never cache identities globally.
106
+ * @param {string[]} keys - Missing keys requested by the caller
107
+ * @param {Object} [existing] - Existing or explicitly supplied values (plain or encrypted)
108
+ * @param {string} [encryptionKey] - Local encryption key
109
+ * @returns {Object} Newly generated values only
110
+ */
111
+ function generateRsaSecretValues(keys, existing = {}, encryptionKey) {
112
+ let catalog;
113
+ try {
114
+ catalog = require('./infra-parameter-catalog').getInfraParameterCatalog();
115
+ } catch {
116
+ return {}; // Ordinary generation retains its existing catalog-unavailable handling.
117
+ }
118
+ const values = {};
119
+ for (const key of keys) {
120
+ if (populated(existing[key]) || populated(values[key])) continue;
121
+ const entry = catalog.findEntryForKey(key);
122
+ if (entry?.generator?.type !== 'rsaKeyPair') continue;
123
+ // Supplied by Mori at deployment and validated against Key Vault. Generating it here
124
+ // would hand the installation the private half of the key it verifies licences against.
125
+ if (isIssuerSuppliedPair(entry)) continue;
126
+ const entries = catalog.data.parameters.filter(e => e.key &&
127
+ e.generator?.type === 'rsaKeyPair' && e.generator.pairId === entry.generator.pairId);
128
+ Object.assign(values, generatePair(entries, { ...existing, ...values }, encryptionKey));
129
+ }
130
+ return values;
131
+ }
132
+
133
+ /**
134
+ * The rsaKeyPair branch, which is the only one that can refuse.
135
+ *
136
+ * Builder never mints a pair whose private half belongs to the issuer: an installation
137
+ * holding it could sign a licence it would then accept. Miso's catalog has since changed
138
+ * these three entries to `emptyAllowed`, so this is now a guard against the declaration
139
+ * coming back rather than a branch the shipped catalog reaches.
140
+ *
141
+ * @param {string} key - Secret key
142
+ * @param {Object} catalogEntry - Catalog entry
143
+ * @returns {string|undefined}
144
+ * @throws {Error} When the pair is issuer-supplied
145
+ */
146
+ function generateRsaValueFromCatalog(key, catalogEntry) {
147
+ if (isIssuerSuppliedPair(catalogEntry)) {
148
+ throw new Error(
149
+ `Secret "${key}" is supplied by Mori and is never generated here. Licence trust is a ` +
150
+ 'two-signature chain: Mori signs the licence, which is verified against the on-disk ' +
151
+ 'public key, and that material is validated against a Key Vault key readable only by ' +
152
+ 'Miso\'s managed identity. The public half reaches an installation through the ' +
153
+ 'moriLicensePublicKey deployment parameter. An installation with neither stays ' +
154
+ 'unpinned and refuses every licence, which is the intended failure.'
155
+ );
156
+ }
157
+ return generateRsaSecretValues([key])[key];
158
+ }
159
+
160
+ module.exports = {
161
+ generateRsaSecretValues,
162
+ generateRsaValueFromCatalog,
163
+ isIssuerSuppliedPair,
164
+ ISSUER_SUPPLIED_PAIR_IDS
165
+ };
@@ -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
@@ -45,6 +45,30 @@ function listChannelArtifacts(ctx) {
45
45
  return { artifacts };
46
46
  }
47
47
 
48
+ /**
49
+ * Locate the rendered setup document.
50
+ *
51
+ * `dist/channels/<target>/` is build output and is gitignored, so anything served only from
52
+ * there disappears on a clean, a rebuild, or in a fresh workspace — which is what made the
53
+ * portal report "Setup document not available" after an install that had succeeded. The
54
+ * durable copy lives beside the channel's other committed files under
55
+ * `integration/<systemKey>/`, so that is preferred and `dist/` is only a fallback for a
56
+ * package built but not yet persisted.
57
+ *
58
+ * @param {import('./workspace-context').WorkspaceContext} ctx - Workspace context
59
+ * @param {string} root - User root
60
+ * @param {string} target - Channel target / system key
61
+ * @returns {string} Absolute path, or '' when no copy exists
62
+ */
63
+ function resolveInstallGuidePath(ctx, root, target) {
64
+ const candidates = [];
65
+ if (typeof ctx.getIntegrationPath === 'function') {
66
+ candidates.push(path.join(ctx.getIntegrationPath(), 'INSTALL.md'));
67
+ }
68
+ candidates.push(path.join(path.dirname(getArtifactOutputPath(root, target)), 'INSTALL.md'));
69
+ return candidates.find(candidate => fs.existsSync(candidate)) || '';
70
+ }
71
+
48
72
  /**
49
73
  * @param {import('./workspace-context').WorkspaceContext} ctx
50
74
  * @param {string} artifactId
@@ -68,8 +92,8 @@ function resolveArtifactFile(ctx, artifactId) {
68
92
  }
69
93
  }
70
94
  if (id === 'installMd') {
71
- const absPath = path.join(path.dirname(getArtifactOutputPath(root, target)), 'INSTALL.md');
72
- if (fs.existsSync(absPath)) {
95
+ const absPath = resolveInstallGuidePath(ctx, root, target);
96
+ if (absPath) {
73
97
  return { absPath, contentType: 'text/markdown; charset=utf-8', fileName: 'INSTALL.md' };
74
98
  }
75
99
  }
@@ -131,6 +155,8 @@ async function handleChannelCallback(ctx, provider, query, body, deps = {}) {
131
155
 
132
156
  module.exports = {
133
157
  listChannelArtifacts,
158
+ resolveArtifactFile,
159
+ resolveInstallGuidePath,
134
160
  getChannelArtifact,
135
161
  handleChannelCallback,
136
162
  storeChannelCallbackCredential
@@ -119,7 +119,9 @@ function generatePackageArtifact(ctx, body, channelTarget, branding, dataplaneUr
119
119
  projectRoot: ctx.getUserRoot(),
120
120
  channelTarget,
121
121
  runtimeValues,
122
- dryRun: body.dryRun === true
122
+ dryRun: body.dryRun === true,
123
+ // Persist the setup document beside the channel's committed files; dist/ is disposable.
124
+ durableDir: workspaceSystemDir(ctx)
123
125
  });
124
126
  }
125
127
 
@@ -27,6 +27,38 @@ const WORKSPACE_INTEGRATION_FORMAT = 'json';
27
27
  * @property {string} bearerToken
28
28
  */
29
29
 
30
+ /**
31
+ * The root a given environment's work lives under.
32
+ *
33
+ * `tst` and `pro` get a path segment of their own so an environment's work is separable and
34
+ * can be deleted on its own; `dev` keeps the existing layout, so no in-flight workspace moves
35
+ * and nothing that already holds a path stops resolving.
36
+ *
37
+ * Because `dev` is unprefixed, a user id equal to a promotion-only environment name would
38
+ * make `<root>/pro` mean two things — that environment, or that user's dev work. `userId`
39
+ * comes from a token and only has to avoid traversal to pass validation, so the collision is
40
+ * refused here rather than assumed away.
41
+ *
42
+ * @param {string} workspaceRoot - Resolved WORKSPACE_ROOT
43
+ * @param {string} userId - Validated user id
44
+ * @param {string} [environment] - Environment key
45
+ * @returns {string} Root for this environment
46
+ * @throws {Error} When the user id would collide with an environment segment
47
+ */
48
+ function resolveEnvironmentWorkspaceRoot(workspaceRoot, userId, environment) {
49
+ const { isPromotionOnlyEnvironment } = require('../core/environment-mode-policy');
50
+ if (isPromotionOnlyEnvironment(userId)) {
51
+ throw new Error(
52
+ `userId "${userId}" collides with an environment workspace segment; it cannot be an ` +
53
+ 'environment name'
54
+ );
55
+ }
56
+ if (!isPromotionOnlyEnvironment(environment)) {
57
+ return workspaceRoot;
58
+ }
59
+ return path.join(workspaceRoot, String(environment).trim().toLowerCase());
60
+ }
61
+
30
62
  /**
31
63
  * @param {string} segment
32
64
  * @returns {string}
@@ -63,7 +95,9 @@ function createWorkspaceContext(params) {
63
95
  }
64
96
 
65
97
  const workspaceRoot = resolveWorkspaceRoot(params.workspaceRoot);
66
- const userRoot = path.join(workspaceRoot, userId);
98
+ const environment = params.environment || process.env.DEFAULT_ENVIRONMENT || 'dev';
99
+ const environmentRoot = resolveEnvironmentWorkspaceRoot(workspaceRoot, userId, environment);
100
+ const userRoot = path.join(environmentRoot, userId);
67
101
  const integrationRoot = path.join(userRoot, 'integration');
68
102
  const useExplicitIntegration = Boolean(params.integrationPath);
69
103
  const integrationPath = useExplicitIntegration
@@ -82,7 +116,7 @@ function createWorkspaceContext(params) {
82
116
  userId,
83
117
  workspaceRoot,
84
118
  systemKey,
85
- environment: params.environment || process.env.DEFAULT_ENVIRONMENT || 'dev',
119
+ environment,
86
120
  controllerUrl:
87
121
  params.controllerUrl ||
88
122
  process.env.MISO_CONTROLLER_URL ||
@@ -149,5 +183,6 @@ module.exports = {
149
183
  runWithWorkspaceContext,
150
184
  ensureIntegrationDirectory,
151
185
  resolveWorkspaceRoot,
186
+ resolveEnvironmentWorkspaceRoot,
152
187
  assertPathContained
153
188
  };
@@ -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.",