@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.
- package/README.md +14 -11
- package/docs/README.md +80 -0
- package/docs/builder-help/evidence-patterns.json +155 -0
- package/docs/builder-help/golden-examples/crm-company.json +30 -0
- package/docs/builder-help/golden-examples/crm-deal.json +29 -0
- package/docs/builder-help/golden-examples/document-storage-keyed-get.json +85 -0
- package/docs/builder-help/golden-examples/document-storage.json +30 -0
- package/docs/builder-help/golden-examples/meeting-transcript.json +29 -0
- package/docs/builder-help/golden-examples/repository-template.json +29 -0
- package/docs/builder-help/golden-examples/service-ticket.json +29 -0
- package/docs/builder-help/platform-roles.json +98 -0
- package/docs/builder-help/resource-type-catalog.json +402 -0
- package/lib/api/configuration.api.js +131 -0
- package/lib/api/system-secrets.api.js +72 -0
- package/lib/app/run-docker-fallback.js +6 -1
- package/lib/app/run-helpers.js +2 -1
- package/lib/app/run-parameter-sync.js +142 -0
- package/lib/build/docker-build-args.js +5 -2
- package/lib/build/index.js +3 -2
- package/lib/build/standard-docker-build.js +2 -2
- package/lib/cli/setup-app.js +5 -0
- package/lib/cli/setup-environment.js +156 -0
- package/lib/commands/datasource-capability-upsert-cli.js +112 -0
- package/lib/commands/datasource-capability.js +4 -2
- package/lib/commands/env-secret-context.js +113 -0
- package/lib/commands/env-secret-list.js +200 -0
- package/lib/commands/env-secret-push-confirm.js +54 -0
- package/lib/commands/env-secret-push-run.js +227 -0
- package/lib/commands/env-secret-push.js +168 -0
- package/lib/commands/repair-datasource-apply.js +2 -0
- package/lib/commands/repair-datasource-keyed-document.js +122 -0
- package/lib/commands/repair-datasource-run.js +1 -0
- package/lib/commands/setup-modes.js +1 -8
- package/lib/commands/setup-prompts.js +2 -180
- package/lib/commands/verify-operations-skip-e2e.js +25 -1
- package/lib/commands/verify-operations-steps.js +13 -1
- package/lib/commands/wizard-config-normalizer.js +7 -4
- package/lib/commands/wizard-core.js +5 -157
- package/lib/commands/wizard-file-saving.js +163 -0
- package/lib/core/env-platform-expand.js +5 -1
- package/lib/core/secrets-env-content.js +5 -1
- package/lib/core/secrets-env-write.js +10 -3
- package/lib/core/secrets-load.js +4 -2
- package/lib/datasource/binary-documents-validator.js +190 -0
- package/lib/datasource/capability/run-capability-upsert.js +202 -0
- package/lib/datasource/capability/upsert-ingredients.js +291 -0
- package/lib/datasource/capability/upsert-operations.js +138 -0
- package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
- package/lib/datasource/validate.js +12 -5
- package/lib/generator/index.js +3 -0
- package/lib/lifecycle/product-model.js +4 -3
- package/lib/lifecycle/report-display.js +3 -2
- package/lib/parameters/infra-parameter-catalog.js +1 -1
- package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
- package/lib/programmatic/builder-help-governance.js +1 -1
- package/lib/programmatic/builder-help.js +1 -1
- package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
- package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
- package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
- package/lib/role-assistant/test-runner-workhub.js +28 -19
- package/lib/schema/application-schema.json +14 -2
- package/lib/schema/external-datasource.schema.json +23 -3
- package/lib/schema/infra.parameter.yaml +388 -38
- package/lib/utils/compose-generate-docker-compose.js +7 -12
- package/lib/utils/datasource-binary-evidence.js +92 -0
- package/lib/utils/datasource-test-run-capability-scope.js +44 -1
- package/lib/utils/datasource-test-run-debug-display.js +2 -0
- package/lib/utils/datasource-test-run-display.js +8 -2
- package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
- package/lib/utils/datasource-test-run-tty-log.js +2 -0
- package/lib/utils/env-copy.js +11 -10
- package/lib/utils/external-system-system-test-tty.js +3 -2
- package/lib/utils/image-tags.js +2 -2
- package/lib/utils/platform-kv-ref.js +1 -1
- package/lib/utils/prepare-local-data-mount.js +58 -0
- package/lib/utils/secrets-helpers.js +0 -1
- package/lib/utils/system-secret-mapping.js +125 -0
- package/lib/utils/test-log-writer.js +2 -1
- package/lib/validation/external-manifest-validator.js +5 -0
- package/lib/validation/openapi-contract-surface-validator.js +3 -1
- package/lib/validation/validate-external-file.js +5 -1
- package/package.json +4 -3
- package/templates/agent-kit/agent-kit.yaml +1 -1
- package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
- package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
- package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -2
- package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +6 -2
- package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
- package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +7 -1
- package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
- package/templates/agent-kit/skills/shared/interaction.md +83 -0
- package/templates/agent-kit/skills/shared/status.md +3 -12
- package/templates/applications/builder-api/application.yaml +1 -1
- package/templates/applications/builder-api/env.template +5 -1
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/env.template +10 -12
- package/templates/applications/keycloak/application.yaml +6 -1
- package/templates/applications/miso-controller/application.yaml +74 -2
- package/templates/applications/miso-controller/env.template +42 -3
- package/templates/applications/miso-controller/rbac.yaml +17 -0
- package/templates/external-system/external-datasource.yaml.hbs +7 -1
- package/templates/marketplace/main.json +4 -4
- package/templates/python/docker-compose.hbs +1 -1
- package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -41
- /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
- /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
- /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
|
}
|
package/lib/generator/index.js
CHANGED
|
@@ -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
|
|
136
|
-
const
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
295
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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-
|
|
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.",
|