@aifabrix/builder 2.55.0 → 2.56.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 (101) hide show
  1. package/lib/agent-kit/host-approve.js +48 -6
  2. package/lib/agent-kit/init.js +2 -2
  3. package/lib/agent-kit/remote-prompts.js +46 -13
  4. package/lib/agent-kit/run.js +1 -1
  5. package/lib/agent-kit/start.js +310 -0
  6. package/lib/api/pipeline.api.js +32 -3
  7. package/lib/app/deploy.js +13 -9
  8. package/lib/app/offer-create-from-template.js +184 -0
  9. package/lib/cli/setup-app.js +15 -7
  10. package/lib/cli/setup-auth.js +11 -2
  11. package/lib/cli/setup-external-system-help.js +15 -3
  12. package/lib/cli/setup-external-system.js +21 -3
  13. package/lib/commands/agent-kit-help.js +74 -4
  14. package/lib/commands/agent-kit.js +83 -12
  15. package/lib/commands/auth-status-user.js +99 -0
  16. package/lib/commands/auth-status.js +14 -4
  17. package/lib/commands/datasource-load-export-cli.js +7 -2
  18. package/lib/commands/datasource-test-trust-cli.js +5 -1
  19. package/lib/commands/governance-command-action.js +5 -4
  20. package/lib/commands/identity-membership-role-cache.js +6 -1
  21. package/lib/commands/lifecycle-command-action.js +16 -15
  22. package/lib/commands/login-credentials.js +8 -3
  23. package/lib/commands/login-device.js +14 -5
  24. package/lib/commands/login.js +14 -1
  25. package/lib/commands/logout.js +50 -15
  26. package/lib/commands/repair-datasource-auto-rbac.js +4 -9
  27. package/lib/commands/role-assistant.js +24 -12
  28. package/lib/commands/upload-openapi-sync-followup.js +59 -0
  29. package/lib/commands/upload-qualification-client.js +131 -0
  30. package/lib/commands/upload-qualification-followups.js +104 -0
  31. package/lib/commands/upload-qualification-poll.js +143 -0
  32. package/lib/commands/upload.js +37 -156
  33. package/lib/commands/verify-governance-command-action.js +9 -7
  34. package/lib/commands/verify-operations-command-action.js +9 -8
  35. package/lib/commands/verify-trust-command-action.js +6 -5
  36. package/lib/deployment/deploy-poll-ui.js +14 -10
  37. package/lib/deployment/deployer-status.js +4 -4
  38. package/lib/external-system/deploy.js +46 -51
  39. package/lib/external-system/download.js +1 -2
  40. package/lib/generator/helpers.js +14 -46
  41. package/lib/generator/index.js +0 -4
  42. package/lib/integration-definition/apply-static-enterprise.js +5 -2
  43. package/lib/programmatic/builder-api-request.js +8 -4
  44. package/lib/programmatic/capability-registry.js +2 -0
  45. package/lib/programmatic/get-deploy-status.js +45 -0
  46. package/lib/programmatic/get-upload-status.js +45 -0
  47. package/lib/programmatic/http-accepted-response.js +31 -0
  48. package/lib/programmatic/openapi-descriptions.js +14 -4
  49. package/lib/programmatic/request-timeout.js +21 -15
  50. package/lib/programmatic/route-handler-map.js +6 -0
  51. package/lib/programmatic/run-deploy.js +7 -3
  52. package/lib/programmatic/run-upload.js +11 -7
  53. package/lib/role-assistant/knowledge-sync-settings-push.js +172 -0
  54. package/lib/role-assistant/knowledge-sync.js +9 -126
  55. package/lib/role-assistant/test-cases-evaluate.js +12 -1
  56. package/lib/role-assistant/test-cases-evidence.js +44 -5
  57. package/lib/role-assistant/test-cases-lifecycle.js +9 -3
  58. package/lib/role-assistant/test-runner.js +3 -0
  59. package/lib/schema/infra-parameter.schema.json +51 -3
  60. package/lib/schema/infra.parameter.yaml +482 -47
  61. package/lib/schema/type/document-storage.json +1 -1
  62. package/lib/utils/api-required-permissions.js +271 -0
  63. package/lib/utils/api.js +16 -13
  64. package/lib/utils/cli-json-exit.js +41 -0
  65. package/lib/utils/cli-utils.js +26 -7
  66. package/lib/utils/controller-url.js +78 -4
  67. package/lib/utils/deployment-errors.js +15 -0
  68. package/lib/utils/deployment-validation-helpers.js +4 -1
  69. package/lib/utils/device-code.js +31 -10
  70. package/lib/utils/error-formatters/error-parser.js +16 -1
  71. package/lib/utils/error-formatters/permission-errors.js +19 -5
  72. package/lib/utils/keycloak-realm-ready.js +5 -2
  73. package/lib/utils/logger.js +1 -0
  74. package/lib/utils/paths.js +26 -5
  75. package/lib/utils/qualification-poll-ui.js +136 -0
  76. package/lib/utils/secrets-helpers.js +2 -2
  77. package/lib/utils/write-stream-and-wait.js +63 -0
  78. package/package.json +22 -7
  79. package/templates/agent-kit/agent-kit.yaml +1 -1
  80. package/templates/agent-kit/instructions/AGENTKIT.md +25 -4
  81. package/templates/agent-kit/instructions/root.AGENTS.md +1 -1
  82. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
  83. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +1 -1
  84. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +2 -1
  85. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +1 -0
  86. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +1 -0
  87. package/templates/agent-kit/skills/shared/hosts.md +13 -26
  88. package/templates/agent-kit/skills/shared/login.md +16 -0
  89. package/templates/agent-kit/workspace/README.md +1 -1
  90. package/templates/applications/builder-api/env.template +4 -0
  91. package/templates/applications/dataplane/application.yaml +1 -2
  92. package/templates/applications/dataplane/env.template +5 -5
  93. package/templates/applications/keycloak/Dockerfile +12 -5
  94. package/templates/applications/keycloak/application.yaml +2 -2
  95. package/templates/applications/keycloak/cimd-lenient-parse/AbstractClientIdMetadataDocumentExecutor.java +3 -3
  96. package/templates/applications/keycloak/cimd-lenient-parse/SOURCE.md +6 -6
  97. package/templates/applications/keycloak/env.template +13 -1
  98. package/templates/applications/miso-controller/application.yaml +21 -2
  99. package/templates/applications/miso-controller/env.template +97 -28
  100. package/templates/applications/miso-controller/rbac.yaml +5 -0
  101. package/lib/generator/deploy-manifest-azure-kv.js +0 -81
@@ -132,11 +132,54 @@ async function clearClientTokens(options) {
132
132
  return 0;
133
133
  }
134
134
 
135
+ function normalizeTokenType(value) {
136
+ const type = value && String(value).toLowerCase();
137
+ if (type && type !== 'device' && type !== 'client') {
138
+ throw new Error('--type must be either "device" or "client"');
139
+ }
140
+ return type;
141
+ }
142
+
143
+ function validateLogoutOptionCombination(options, type) {
144
+ if (options.app && !options.environment) {
145
+ throw new Error('--app requires --environment option');
146
+ }
147
+ if (type === 'client' && options.controller) {
148
+ throw new Error('--controller can only be used with --type device');
149
+ }
150
+ if (type === 'device' && (options.environment || options.app)) {
151
+ throw new Error('--environment and --app can only be used with --type client');
152
+ }
153
+ }
154
+
155
+ function validateLogoutSelectors(options) {
156
+ if ('controller' in options && options.controller !== undefined && options.controller !== null) {
157
+ validateControllerUrl(options.controller);
158
+ }
159
+ if ('environment' in options && options.environment !== undefined && options.environment !== null) {
160
+ validateEnvironmentKey(options.environment);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Validate logout option combinations and normalize the token type.
166
+ * @param {Object} options - Raw logout options
167
+ * @returns {Object} Validated options with a normalized type
168
+ * @throws {Error} If an option or option combination is invalid
169
+ */
170
+ function normalizeLogoutOptions(options) {
171
+ const type = normalizeTokenType(options.type);
172
+ validateLogoutOptionCombination(options, type);
173
+ validateLogoutSelectors(options);
174
+ return { ...options, type };
175
+ }
176
+
135
177
  /**
136
178
  * Handle logout command action
137
179
  * @async
138
180
  * @function handleLogout
139
181
  * @param {Object} options - Logout options
182
+ * @param {string} [options.type] - Token type to clear (device or client)
140
183
  * @param {string} [options.controller] - Clear device tokens for specific controller
141
184
  * @param {string} [options.environment] - Clear client tokens for specific environment
142
185
  * @param {string} [options.app] - Clear client tokens for specific app (requires --environment)
@@ -147,24 +190,17 @@ async function handleLogout(options) {
147
190
  const configPath = CONFIG_FILE;
148
191
  logger.log(chalk.blue('\n🔓 Clearing authentication tokens...\n'));
149
192
 
150
- // Validate options
151
- if (options.app && !options.environment) {
152
- throw new Error('--app requires --environment option');
153
- }
154
-
155
- if ('controller' in options && options.controller !== undefined && options.controller !== null) {
156
- validateControllerUrl(options.controller);
157
- }
158
-
159
- if ('environment' in options && options.environment !== undefined && options.environment !== null) {
160
- validateEnvironmentKey(options.environment);
161
- }
193
+ const normalizedOptions = normalizeLogoutOptions(options);
162
194
 
163
195
  // Clear device tokens
164
- const deviceTokensCleared = await clearDeviceTokens(options);
196
+ const deviceTokensCleared = normalizedOptions.type === 'client'
197
+ ? 0
198
+ : await clearDeviceTokens(normalizedOptions);
165
199
 
166
200
  // Clear client tokens
167
- const clientTokensCleared = await clearClientTokens(options);
201
+ const clientTokensCleared = normalizedOptions.type === 'device'
202
+ ? 0
203
+ : await clearClientTokens(normalizedOptions);
168
204
 
169
205
  // Summary
170
206
  const totalCleared = deviceTokensCleared + clientTokensCleared;
@@ -178,4 +214,3 @@ async function handleLogout(options) {
178
214
  }
179
215
 
180
216
  module.exports = { handleLogout };
181
-
@@ -34,18 +34,14 @@ function buildRenameMap(operationMap) {
34
34
  if (!operationMap || typeof operationMap !== 'object' || Array.isArray(operationMap)) return {};
35
35
  const renameMap = {};
36
36
  for (const k of Object.keys(operationMap)) {
37
- // If it's already schema-valid but contains capitals, normalize to lowercase to align
38
- // with RBAC permission name restrictions (external-system schema forbids A-Z).
37
+ // Operation keys use the capability naming contract, which allows camelCase.
38
+ // RBAC permission names are normalized separately when permissions are generated.
39
39
  if (/^[a-z][a-zA-Z0-9]*$/.test(k)) {
40
- if (/[A-Z]/.test(k)) {
41
- renameMap[k] = k.toLowerCase();
42
- }
43
40
  continue;
44
41
  }
45
42
  const camel = toCamelCaseKey(k);
46
43
  if (!camel || camel === k) continue;
47
- // Use lowercase key to keep RBAC permission names schema-valid.
48
- renameMap[k] = camel.toLowerCase();
44
+ renameMap[k] = camel;
49
45
  }
50
46
  return renameMap;
51
47
  }
@@ -212,7 +208,7 @@ function normalizeAutoRbacOperationKeys(parsed, changes) {
212
208
  if (updated && Array.isArray(changes)) {
213
209
  const pairs = keysToRename.map((k) => `${k}→${renameMap[k]}`).join(', ');
214
210
  changes.push(
215
- `Normalized autoRbac operation keys (permission names are lowercase per schema; kebab aliases fold into canonical keys): ${pairs}`
211
+ `Normalized schema-invalid autoRbac operation aliases to camelCase keys (RBAC permission names remain lowercase): ${pairs}`
216
212
  );
217
213
  }
218
214
 
@@ -224,4 +220,3 @@ module.exports = {
224
220
  toCamelCaseKey,
225
221
  toKebabAliasKey
226
222
  };
227
-
@@ -64,7 +64,8 @@ Subcommands:
64
64
  copy <roleKey> Capture one Work Hub execution as a local YAML test case
65
65
 
66
66
  Package workspace (top-level commands — same digital-worker package):
67
- download / validate / upload / deploy <roleKey> Manage role-assistant/ra-<roleKey>/
67
+ download / validate / upload ra-<roleKey> Manage role-assistant/ra-<roleKey>/
68
+ upload publishes settings and sidecars; operator Make available is a separate action
68
69
 
69
70
  Examples:
70
71
  $ aifabrix role-assistant test customer-support-bot
@@ -72,11 +73,11 @@ Examples:
72
73
  $ aifabrix role-assistant test customer-support-bot --list-cases
73
74
  $ aifabrix role-assistant test customer-support-bot --env tst --json
74
75
  $ aifabrix role-assistant copy customer-support-bot --suite 001 --reference a9f09fb8-d9eb-477f-8bb4-59e670fc82a4
75
- $ aifabrix upload customer-support-bot --env dev
76
+ $ aifabrix upload ra-customer-support-bot --env dev
76
77
  $ aifabrix test ra-customer-support-bot
77
78
 
78
79
  Notes:
79
- - Local packages live under role-assistant/ra-<roleKey>/ (bare <roleKey> in CLI args).
80
+ - role-assistant test/copy use bare <roleKey>; top-level package commands use ra-<roleKey>.
80
81
  - test uses the public/running worker by default; pass --publish (or upload) to refresh from local files.
81
82
  - copy writes tests/<suite>/<caseId>.yaml from a Work Hub execution id; it does not publish.
82
83
  - See: aifabrix role-assistant test -h
@@ -267,19 +268,30 @@ async function runRoleAssistantTestAction(roleKey, options, cmd) {
267
268
  const portalUrl = buildExternalSystemUiUrl(result.dataplaneUiBaseUrl, systemKey);
268
269
  const caseResult = await resolveSelectedCaseResults(systemKey, opts, result, verdict, portalUrl);
269
270
 
271
+ const exitCode = computeRoleAssistantExitCode(verdict, caseResult);
272
+
270
273
  if (opts.json) {
271
- logger.log(
272
- JSON.stringify(
273
- buildRoleAssistantTestEnvelope(displayKey, verdict, portalUrl, result, opts, caseResult),
274
- null,
275
- 2
276
- )
274
+ // Large piped stdout writes on Linux are non-blocking; exiting right
275
+ // after console.log() can truncate the JSON before the OS write
276
+ // completes. Wait for the write to flush before exiting non-zero.
277
+ const payload = JSON.stringify(
278
+ logger.sanitizeValue(
279
+ buildRoleAssistantTestEnvelope(displayKey, verdict, portalUrl, result, opts, caseResult)
280
+ ),
281
+ null,
282
+ 2
277
283
  );
278
- } else {
279
- displayRoleAssistantTestSummary(displayKey, verdict, portalUrl, result, caseResult);
284
+ return new Promise((resolve) => {
285
+ process.stdout.write(`${payload}\n`, () => {
286
+ if (exitCode !== 0) {
287
+ process.exit(exitCode);
288
+ }
289
+ resolve(exitCode);
290
+ });
291
+ });
280
292
  }
281
293
 
282
- const exitCode = computeRoleAssistantExitCode(verdict, caseResult);
294
+ displayRoleAssistantTestSummary(displayKey, verdict, portalUrl, result, caseResult);
283
295
  if (exitCode !== 0) {
284
296
  process.exit(exitCode);
285
297
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @fileoverview Post-publish local OpenAPI sync follow-up, shared by the synchronous and
3
+ * qualification-async upload paths (extracted from upload.js per plan 191.0 file-size gate).
4
+ * @author AI Fabrix Team
5
+ * @version 1.0.0
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const logger = require('../utils/logger');
11
+ const { formatSuccessLine, formatWarningLine } = require('../utils/cli-test-layout-chalk');
12
+ const { resolveExternalSystemPath } = require('../utils/app-path-resolve');
13
+ const { maybeSyncOpenApiFilesForMcp } = require('./repair-openapi-sync');
14
+ const { exportProgrammaticOpenApiForRepair } = require('./repair-openapi-export');
15
+
16
+ /**
17
+ * Upload local `integration/<systemKey>/openapi/*.json` specs when present so dataplane can store
18
+ * vendor OpenAPI keyed by each datasource `openapi.documentKey` (same behavior as repair --api OpenAPI sync).
19
+ * Call this **after** the external system row exists (successful publish or completed qualification):
20
+ * list/upload endpoints require it. First publish still succeeds because pipeline materializes
21
+ * internal specs from `openapi.operations` when needed.
22
+ *
23
+ * @param {string} systemKey
24
+ * @param {Object} manifest
25
+ * @param {Object} [uploadContext]
26
+ * @returns {Promise<void>}
27
+ */
28
+ async function logAndSyncLocalOpenApiForUpload(systemKey, manifest, uploadContext = {}) {
29
+ const pathOptions = uploadContext.pathOptions || {};
30
+ const appPath = resolveExternalSystemPath(systemKey, pathOptions);
31
+ const ei = manifest && manifest.externalIntegration;
32
+ const datasourceFiles = ei && Array.isArray(ei.dataSources) ? ei.dataSources : [];
33
+ await exportProgrammaticOpenApiForRepair({
34
+ appPath,
35
+ systemKey,
36
+ datasourceFiles,
37
+ dryRun: false,
38
+ backupCtx: { dryRun: false, changedFiles: [] }
39
+ });
40
+ const openapiSyncLines = await maybeSyncOpenApiFilesForMcp({
41
+ enabled: true,
42
+ dryRun: false,
43
+ appPath,
44
+ systemKey,
45
+ datasourceFiles,
46
+ dataplaneUrl: uploadContext.dataplaneUrl,
47
+ authConfig: uploadContext.authConfig,
48
+ // Always replace SpecFile rows keyed by openapi.documentKey so vendor/full
49
+ // specs are not left missing while only thin internal:* slices remain.
50
+ force: true
51
+ });
52
+ if (!uploadContext.quiet) {
53
+ for (const line of openapiSyncLines) {
54
+ logger.log(line.startsWith('Skipped') ? formatWarningLine(line) : formatSuccessLine(line));
55
+ }
56
+ }
57
+ }
58
+
59
+ module.exports = { logAndSyncLocalOpenApiForUpload };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @fileoverview Resumable Dataplane qualification upload: accept, --wait/--no-wait, poll, resume.
3
+ * Mirrors dev-host-ops.js's job id + wait/no-wait contract (plan 191.0 task 1). Never invents
4
+ * qualification truth — accept/poll surface exactly what Dataplane returns.
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const logger = require('../utils/logger');
12
+ const { formatSuccessLine, formatWarningLine } = require('../utils/cli-test-layout-chalk');
13
+ const { formatApiError } = require('../utils/api-error-handler');
14
+ const { uploadApplicationViaPipelineWithAuthRetry } = require('../utils/pipeline-upload-auth-retry');
15
+ const { pollQualificationJobUntilTerminal } = require('./upload-qualification-poll');
16
+ const { createQualificationPollHandlers } = require('../utils/qualification-poll-ui');
17
+
18
+ /**
19
+ * Ctrl-C / disconnect during polling must detach without corrupting truth (plan 191.0 task 4):
20
+ * the qualification job keeps running on Dataplane regardless of whether this CLI process exits.
21
+ * This only makes that safe fact visible, printing the resume command before exiting 130.
22
+ * @param {string} jobId
23
+ * @param {string} systemKey
24
+ * @returns {() => void} Call to remove the listeners once polling finishes normally.
25
+ */
26
+ function registerUploadInterruptHint(jobId, systemKey) {
27
+ const onSignal = () => {
28
+ logger.log(
29
+ formatWarningLine(
30
+ `Interrupted — upload job ${jobId} is still running, unaffected. Resume with: aifabrix upload ${systemKey} --resume ${jobId}`
31
+ )
32
+ );
33
+ process.exit(130);
34
+ };
35
+ process.once('SIGINT', onSignal);
36
+ process.once('SIGTERM', onSignal);
37
+ return () => {
38
+ process.removeListener('SIGINT', onSignal);
39
+ process.removeListener('SIGTERM', onSignal);
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Submit the candidate for durable Dataplane qualification (async). Always returns quickly —
45
+ * a 202 accept, or the current state when an equivalent job is already in flight — never blocks
46
+ * for the ladder to finish.
47
+ * @param {string} dataplaneUrl
48
+ * @param {Object} authConfig
49
+ * @param {Object} payload
50
+ * @param {string} systemKey
51
+ * @param {Object} [options] - Forwarded to uploadApplicationViaPipeline (e.g. timeoutMs, ignored when async)
52
+ * @returns {Promise<{jobId: string, state: string, resumed: boolean, candidateFingerprint: string}>}
53
+ */
54
+ async function acceptQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options = {}) {
55
+ const res = await uploadApplicationViaPipelineWithAuthRetry(dataplaneUrl, authConfig, payload, systemKey, {
56
+ ...options,
57
+ async: true
58
+ });
59
+ if (res?.success === false) {
60
+ const err = new Error(formatApiError(res, dataplaneUrl));
61
+ const status = Number(res.status || res.statusCode);
62
+ err.statusCode = status >= 400 && status < 600 ? status : 400;
63
+ throw err;
64
+ }
65
+ return res.data;
66
+ }
67
+
68
+ /**
69
+ * Poll an existing qualification job id to terminal state (resume without re-uploading).
70
+ * @param {string} dataplaneUrl
71
+ * @param {Object} authConfig
72
+ * @param {string} jobId
73
+ * @param {string} systemKey - For the printed resume hint if interrupted mid-poll.
74
+ * @param {{ json?: boolean, silent?: boolean, timeoutMs?: number|string }} [options] - `timeoutMs`
75
+ * bounds only how long this CLI process waits (CLI `--timeout`); Dataplane keeps running the
76
+ * job regardless, so timing out returns a resumable jobId, never a fabricated failure.
77
+ * @returns {Promise<Object>} Terminal job status
78
+ */
79
+ async function resumeQualificationUpload(dataplaneUrl, authConfig, jobId, systemKey, options = {}) {
80
+ const pollUi = options.json || options.silent
81
+ ? createQualificationPollHandlers(jobId, { silent: true })
82
+ : createQualificationPollHandlers(jobId, { verbose: options.verbose === true });
83
+ const unregisterInterruptHint = registerUploadInterruptHint(jobId, systemKey);
84
+ try {
85
+ return await pollQualificationJobUntilTerminal(
86
+ dataplaneUrl,
87
+ authConfig,
88
+ jobId,
89
+ pollUi,
90
+ options.timeoutMs !== undefined ? Number(options.timeoutMs) : undefined
91
+ );
92
+ } finally {
93
+ pollUi.finish();
94
+ unregisterInterruptHint();
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Accept then, unless `--no-wait`, poll to terminal. On `--no-wait`, prints/returns the
100
+ * resumable job id immediately and never blocks.
101
+ * @param {string} dataplaneUrl
102
+ * @param {Object} authConfig
103
+ * @param {Object} payload
104
+ * @param {string} systemKey
105
+ * @param {{ wait?: boolean, json?: boolean, timeoutMs?: number|string }} [options]
106
+ * @returns {Promise<{jobId: string, candidateFingerprint: string, terminal: Object|null}>}
107
+ */
108
+ async function runQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options = {}) {
109
+ const accepted = await acceptQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options);
110
+ const wait = options.wait !== false;
111
+ if (!wait) {
112
+ if (options.json) {
113
+ logger.log(JSON.stringify({ ...accepted, terminal: null }, null, 2));
114
+ } else {
115
+ logger.log(
116
+ formatSuccessLine(
117
+ `Upload queued (job ${accepted.jobId}). Resume with: aifabrix upload ${systemKey} --resume ${accepted.jobId}`
118
+ )
119
+ );
120
+ }
121
+ return { ...accepted, terminal: null };
122
+ }
123
+ const terminal = await resumeQualificationUpload(dataplaneUrl, authConfig, accepted.jobId, systemKey, options);
124
+ return { ...accepted, terminal };
125
+ }
126
+
127
+ module.exports = {
128
+ acceptQualificationUpload,
129
+ resumeQualificationUpload,
130
+ runQualificationUpload
131
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @fileoverview Post-qualification follow-ups for the async upload path: credential push,
3
+ * local OpenAPI sync, sidecar governance/evidence sync, probe and a qualification-specific
4
+ * summary (plan 191.0 task 1).
5
+ *
6
+ * Note: this intentionally does not reconstruct the legacy tiered datasource-readiness summary
7
+ * (`logUploadReadinessSummary`) that the synchronous path shows — that summary classifies real
8
+ * server-assigned datasource state (status/isActive/mcpContract) which requires a fresh
9
+ * Dataplane fetch+remap not yet ported to this path. The qualification summary below reports
10
+ * what Dataplane actually returned instead of approximating server state from local data.
11
+ * @author AI Fabrix Team
12
+ * @version 1.0.0
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const logger = require('../utils/logger');
18
+ const { formatSuccessLine, metadata, sectionTitle } = require('../utils/cli-test-layout-chalk');
19
+ const { pushAndLogCredentialSecrets } = require('./upload-credential-push-log');
20
+ const { finalizeUploadPublishResult } = require('./upload-data-sync-finalize');
21
+ const { runUploadSidecarFollowups } = require('./upload-sidecar-followups');
22
+ const { logAndSyncLocalOpenApiForUpload } = require('./upload-openapi-sync-followup');
23
+ const { maybeRunUploadProbe } = require('./upload-probe');
24
+
25
+ /**
26
+ * @param {string} systemKey
27
+ * @param {Object} _accepted - Machine-readable qualification identity (not shown in CLI output)
28
+ * @param {Object} job - Terminal PublishJobStatusResponse data
29
+ * @param {Object} options
30
+ */
31
+ function logQualifiedUploadSummary(systemKey, _accepted, job, options) {
32
+ if (options.suppressSummary === true || options.json === true) {
33
+ return;
34
+ }
35
+ logger.log('');
36
+ logger.log(sectionTitle('Upload complete'));
37
+ logger.log(metadata(`System: ${systemKey}`));
38
+ if (options.verbose === true) {
39
+ logger.log(metadata(`Job: ${job.jobId}`));
40
+ const certificateIds = job.result && Array.isArray(job.result.certificateIds) ? job.result.certificateIds : [];
41
+ if (certificateIds.length > 0) {
42
+ logger.log(metadata(`Certificates: ${certificateIds.join(', ')}`));
43
+ }
44
+ }
45
+ logger.log(formatSuccessLine(`${systemKey} uploaded successfully.`));
46
+ }
47
+
48
+ /**
49
+ * Runs the same downstream follow-ups as the synchronous publish path (credential push, local
50
+ * OpenAPI sync, sidecar governance/evidence sync, optional probe) once a qualification job has
51
+ * reached `completed`, then finalizes with the same result-envelope shape as the sync path.
52
+ * @param {string} systemKey
53
+ * @param {Object} options
54
+ * @param {Object} manifest
55
+ * @param {Object} payload
56
+ * @param {{ dataplaneUrl: string, authConfig: Object, environment: string, accepted: Object, job: Object }} ctx
57
+ * @returns {Promise<Object>}
58
+ */
59
+ async function completeQualifiedUpload(systemKey, options, manifest, payload, ctx) {
60
+ const quietCompact = options.minimal === true && options.verbose !== true;
61
+ const pushMeta = await pushAndLogCredentialSecrets(ctx.dataplaneUrl, ctx.authConfig, systemKey, payload, {
62
+ json: options.json === true,
63
+ pathOptions: options.pathOptions,
64
+ suppressCredentialLog: quietCompact,
65
+ excludeSecretKeys: options.excludeSecretKeys
66
+ });
67
+ await logAndSyncLocalOpenApiForUpload(systemKey, manifest, {
68
+ dataplaneUrl: ctx.dataplaneUrl,
69
+ authConfig: ctx.authConfig,
70
+ pathOptions: options.pathOptions,
71
+ quiet: quietCompact
72
+ });
73
+ const sidecar = await runUploadSidecarFollowups({
74
+ systemKey,
75
+ options,
76
+ payload,
77
+ dataplaneUrl: ctx.dataplaneUrl,
78
+ authConfig: ctx.authConfig,
79
+ environment: ctx.environment
80
+ });
81
+ logQualifiedUploadSummary(systemKey, ctx.accepted, ctx.job, options);
82
+ if (options.probe === true) {
83
+ await maybeRunUploadProbe(ctx.dataplaneUrl, systemKey, ctx.authConfig, options.probeTimeout, {
84
+ compact: quietCompact
85
+ });
86
+ }
87
+ return finalizeUploadPublishResult(
88
+ {
89
+ dataplaneUrl: ctx.dataplaneUrl,
90
+ authConfig: ctx.authConfig,
91
+ environment: ctx.environment,
92
+ uploadSkipped: false,
93
+ sidecar,
94
+ credentialPush: pushMeta.credentialPush,
95
+ configPush: pushMeta.configPush,
96
+ jobId: ctx.job.jobId,
97
+ candidateFingerprint: ctx.accepted.candidateFingerprint
98
+ },
99
+ payload,
100
+ options
101
+ );
102
+ }
103
+
104
+ module.exports = { completeQualifiedUpload, logQualifiedUploadSummary };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @fileoverview Poll a Dataplane qualification (publish) job until terminal (plan 191.0 task 1).
3
+ * Never invents success and never marks the candidate failed just because the CLI stopped polling —
4
+ * timing out returns a resumable jobId instead.
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { getPublishJobStatus } = require('../api/pipeline.api');
12
+ const { formatApiError } = require('../utils/api-error-handler');
13
+
14
+ const POLL_INTERVAL_MS = 3000;
15
+ const POLL_TIMEOUT_MS = 30 * 60 * 1000;
16
+ const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]);
17
+ const MAX_TRANSIENT_POLL_ERRORS = 20;
18
+
19
+ const GOVERNANCE_SCENARIOS_MISSING = 'GOVERNANCE_SCENARIOS_MISSING';
20
+
21
+ function sleep(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+
25
+ /**
26
+ * Convert repeated server error codes into one user-facing qualification failure.
27
+ * The raw job remains attached to the thrown error for JSON/programmatic callers.
28
+ * @param {Object} job
29
+ * @returns {{ message: string, userFacing: boolean }}
30
+ */
31
+ function formatQualificationJobFailure(job) {
32
+ const raw = String(job && job.error ? job.error : '').trim();
33
+ const parts = raw.split(';').map((part) => part.trim()).filter(Boolean);
34
+ const missingCount = parts.filter((part) => part === GOVERNANCE_SCENARIOS_MISSING).length;
35
+ if (missingCount > 0 && missingCount === parts.length) {
36
+ const scope = `${missingCount} datasource${missingCount === 1 ? '' : 's'}`;
37
+ return {
38
+ message: `Governance checks could not run because scenario packs are missing for ${scope}.`,
39
+ userFacing: true
40
+ };
41
+ }
42
+ const unique = [...new Set(parts)];
43
+ return {
44
+ message: unique.join('; ') || `Upload checks failed for job ${job && job.jobId ? job.jobId : ''}`.trim(),
45
+ userFacing: false
46
+ };
47
+ }
48
+
49
+ /**
50
+ * @param {number|string} status
51
+ * @returns {boolean}
52
+ */
53
+ function isTransientQualificationPollError(status) {
54
+ return TRANSIENT_HTTP_STATUSES.has(Number(status));
55
+ }
56
+
57
+ /**
58
+ * @param {string} dataplaneUrl
59
+ * @param {Object} authConfig
60
+ * @param {string} jobId
61
+ * @param {Object} [pollUi]
62
+ * @returns {Promise<Object>} Raw job status data
63
+ */
64
+ async function fetchQualificationJobWithRetry(dataplaneUrl, authConfig, jobId, pollUi = {}) {
65
+ const started = Date.now();
66
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_POLL_ERRORS; attempt += 1) {
67
+ const res = await getPublishJobStatus(dataplaneUrl, authConfig, jobId);
68
+ if (res?.success !== false) {
69
+ return res.data;
70
+ }
71
+ if (!isTransientQualificationPollError(res.status)) {
72
+ const err = new Error(formatApiError(res, dataplaneUrl));
73
+ const status = Number(res.status);
74
+ err.statusCode = status >= 400 && status < 600 ? status : 400;
75
+ throw err;
76
+ }
77
+ if (attempt >= MAX_TRANSIENT_POLL_ERRORS) {
78
+ throw new Error(
79
+ `The upload service returned HTTP ${res.status} repeatedly while waiting for job ${jobId}.`
80
+ );
81
+ }
82
+ if (typeof pollUi.onTransientError === 'function') {
83
+ pollUi.onTransientError(res.status, Date.now() - started);
84
+ }
85
+ await sleep(POLL_INTERVAL_MS);
86
+ }
87
+ throw new Error(`Failed to check upload job ${jobId} after transient retries`);
88
+ }
89
+
90
+ /**
91
+ * Poll a Dataplane qualification job to terminal state (completed|failed). The timeout only
92
+ * bounds how long *this CLI process* waits — Dataplane keeps running the job regardless; timing
93
+ * out returns a resumable jobId, never a fabricated failure (plan 191.0 task 4).
94
+ * @param {string} dataplaneUrl
95
+ * @param {Object} authConfig
96
+ * @param {string} jobId
97
+ * @param {Object} [pollUi] - { onPollProgress(job), onTransientError(status, elapsedMs) }
98
+ * @param {number} [timeoutMs] - Defaults to POLL_TIMEOUT_MS (30 min); CLI `--timeout` overrides it.
99
+ * @returns {Promise<Object>} Terminal job status data (state: 'completed')
100
+ * @throws {Error} With `.jobId` set, when the job fails or the CLI-side wait times out (resumable).
101
+ */
102
+ async function pollQualificationJobUntilTerminal(
103
+ dataplaneUrl,
104
+ authConfig,
105
+ jobId,
106
+ pollUi = {},
107
+ timeoutMs = POLL_TIMEOUT_MS
108
+ ) {
109
+ const started = Date.now();
110
+ while (Date.now() - started < timeoutMs) {
111
+ const job = await fetchQualificationJobWithRetry(dataplaneUrl, authConfig, jobId, pollUi);
112
+ if (typeof pollUi.onPollProgress === 'function') {
113
+ pollUi.onPollProgress(job);
114
+ }
115
+ if (job.state === 'failed') {
116
+ const failure = formatQualificationJobFailure(job);
117
+ const err = new Error(failure.message);
118
+ err.jobId = jobId;
119
+ err.job = job;
120
+ err.skipDoctor = failure.userFacing;
121
+ throw err;
122
+ }
123
+ if (job.state === 'completed') {
124
+ return job;
125
+ }
126
+ await sleep(POLL_INTERVAL_MS);
127
+ }
128
+ const timeoutErr = new Error(
129
+ `Timed out waiting for upload job ${jobId}. It is still running — resume with: aifabrix upload <systemKey> --resume ${jobId}`
130
+ );
131
+ timeoutErr.jobId = jobId;
132
+ timeoutErr.resumable = true;
133
+ throw timeoutErr;
134
+ }
135
+
136
+ module.exports = {
137
+ POLL_INTERVAL_MS,
138
+ POLL_TIMEOUT_MS,
139
+ isTransientQualificationPollError,
140
+ fetchQualificationJobWithRetry,
141
+ formatQualificationJobFailure,
142
+ pollQualificationJobUntilTerminal
143
+ };