@aifabrix/builder 2.57.0 → 2.59.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 (106) hide show
  1. package/lib/agent-kit/codex-app-server.js +44 -0
  2. package/lib/agent-kit/init.js +4 -17
  3. package/lib/agent-kit/session-report.js +15 -10
  4. package/lib/agent-kit/setup-auth.js +131 -0
  5. package/lib/agent-kit/setup-context.js +118 -0
  6. package/lib/agent-kit/setup-github.js +70 -0
  7. package/lib/agent-kit/setup-managed.js +79 -0
  8. package/lib/agent-kit/setup-process.js +42 -0
  9. package/lib/agent-kit/setup-session.js +53 -0
  10. package/lib/agent-kit/setup-state.js +52 -0
  11. package/lib/agent-kit/setup-workspace.js +113 -0
  12. package/lib/agent-kit/setup.js +84 -0
  13. package/lib/agent-kit/start.js +4 -3
  14. package/lib/agent-kit/tmux-sessions.js +20 -3
  15. package/lib/api/dev.api.js +1 -1
  16. package/lib/api/miso-health.api.js +26 -0
  17. package/lib/api/types/dev.types.js +3 -3
  18. package/lib/app/deploy.js +0 -102
  19. package/lib/app/push.js +98 -41
  20. package/lib/build/standard-docker-build.js +13 -11
  21. package/lib/cli/index.js +4 -0
  22. package/lib/cli/setup-app.js +16 -5
  23. package/lib/cli/setup-dev-path-commands.js +53 -2
  24. package/lib/cli/setup-dev.js +7 -4
  25. package/lib/cli/setup-environment.js +17 -4
  26. package/lib/cli/setup-help.js +86 -0
  27. package/lib/cli/setup-onboarding.js +37 -0
  28. package/lib/cli/setup-utility-repair.js +7 -5
  29. package/lib/commands/agent-kit-help.js +21 -7
  30. package/lib/commands/agent-kit-sessions.js +126 -0
  31. package/lib/commands/agent-kit-setup.js +44 -0
  32. package/lib/commands/agent-kit.js +5 -79
  33. package/lib/commands/dev-init.js +21 -23
  34. package/lib/commands/dev-refresh.js +2 -2
  35. package/lib/commands/dev-registry.js +155 -0
  36. package/lib/commands/dev-show-display.js +10 -2
  37. package/lib/commands/governance-verify-external.js +12 -5
  38. package/lib/commands/repair-datasource.js +9 -9
  39. package/lib/commands/repair.js +45 -8
  40. package/lib/commands/setup-image-refresh.js +26 -0
  41. package/lib/commands/upload-qualification-client.js +56 -11
  42. package/lib/commands/upload-qualification-poll.js +51 -22
  43. package/lib/commands/wizard-dataplane.js +16 -52
  44. package/lib/core/config-attach-extensions.js +4 -1
  45. package/lib/datasource/datasource-validate-summary.js +8 -5
  46. package/lib/datasource/sync-defaults.js +53 -0
  47. package/lib/deployment/installation/azure-infra-stage.js +50 -9
  48. package/lib/deployment/installation/azure-preflight.js +63 -7
  49. package/lib/deployment/installation/azure-readiness.js +59 -7
  50. package/lib/deployment/installation/environment-auth.js +60 -20
  51. package/lib/deployment/installation/environment-stage.js +9 -5
  52. package/lib/deployment/installation/infra-catalog.js +57 -5
  53. package/lib/deployment/installation/infra-manifest.js +22 -2
  54. package/lib/deployment/installation/infra-projection.js +14 -3
  55. package/lib/deployment/installation/registry-auth-mode.js +121 -0
  56. package/lib/deployment/installation/stage-input.js +21 -6
  57. package/lib/deployment/marketplace-environment.js +26 -1
  58. package/lib/generator/builders.js +8 -9
  59. package/lib/generator/index.js +23 -12
  60. package/lib/integration-definition/apply.js +2 -2
  61. package/lib/internal/fs-real-sync.js +4 -1
  62. package/lib/programmatic/bearer-auth.js +3 -62
  63. package/lib/programmatic/builder-api-startup.js +51 -0
  64. package/lib/programmatic/builder-miso-runtime.js +167 -0
  65. package/lib/programmatic/openapi-descriptions.js +3 -3
  66. package/lib/programmatic/openapi-schema-descriptions-core.js +3 -3
  67. package/lib/programmatic/openapi-spec.js +2 -1
  68. package/lib/programmatic/route-handler-map.js +3 -2
  69. package/lib/schema/environment-deploy-request.schema.json +1 -1
  70. package/lib/schema/external-datasource.schema.json +20 -5
  71. package/lib/schema/infra-parameter.schema.json +1 -1
  72. package/lib/schema/infra.parameter.yaml +2 -1
  73. package/lib/schema/infrastructure-schema.json +200 -3
  74. package/lib/schema/installation-environment.schema.json +1 -1
  75. package/lib/schema/wizard-config.schema.json +1 -1
  76. package/lib/utils/app-register-config.js +2 -4
  77. package/lib/utils/build-resolve-image.js +2 -21
  78. package/lib/utils/cli-utils.js +8 -1
  79. package/lib/utils/config-paths.js +24 -29
  80. package/lib/utils/config-registry-preference.js +113 -0
  81. package/lib/utils/dev-init-ssh-merge.js +5 -4
  82. package/lib/utils/dev-user-groups.js +2 -1
  83. package/lib/utils/docker-build.js +53 -19
  84. package/lib/utils/docker-daemon-tls-ca.js +2 -2
  85. package/lib/utils/env-map.js +23 -6
  86. package/lib/utils/error-formatters/validation-errors.js +25 -5
  87. package/lib/utils/help-builder.js +4 -3
  88. package/lib/utils/image-tags.js +88 -0
  89. package/lib/utils/promote-dev-docker-image.js +6 -0
  90. package/lib/utils/registry-credentials.js +236 -0
  91. package/lib/utils/resolve-docker-image-ref.js +78 -9
  92. package/lib/utils/secrets-helpers.js +9 -1
  93. package/lib/utils/token-manager.js +29 -36
  94. package/lib/utils/variable-transformer.js +2 -4
  95. package/package.json +4 -2
  96. package/templates/agent-kit/instructions/AGENTKIT.md +39 -2
  97. package/templates/agent-kit/instructions/root.AGENTS.md +40 -1
  98. package/templates/agent-kit/skills/shared/hosts.md +23 -5
  99. package/templates/agent-kit/workspace/README.md +14 -1
  100. package/templates/applications/builder-api/application.yaml +1 -1
  101. package/templates/applications/dataplane/application.yaml +1 -1
  102. package/templates/applications/dataplane/env.template +1 -1
  103. package/templates/applications/miso-controller/application.yaml +1 -1
  104. package/templates/applications/miso-controller/env.template +1 -1
  105. package/templates/external-system/external-datasource.yaml.hbs +7 -6
  106. package/templates/marketplace/main.json +440 -82
@@ -336,6 +336,8 @@ function logConfigurationSection(p) {
336
336
  logRow('Environment', p.environment);
337
337
  logRow('Controller', p.controller);
338
338
  logRow('Format', p.formatVal);
339
+ logRow('Registry', p.registry);
340
+ if (p.registry) logRow('Registry login', p.registryCredentials);
339
341
  logRow('Platform mode', p.setupPlatformMode);
340
342
  logRow('Traefik', p.traefikEnabled ? 'on' : 'off');
341
343
  logRow('Scoped resources', p.scopedResourcesLabel);
@@ -389,14 +391,16 @@ async function loadDevShowData(devIdStr) {
389
391
  const ports = devConfig.getDevPorts(Number.isNaN(devIdNum) ? 0 : devIdNum);
390
392
  const [
391
393
  environment, tlsEnabled, controller, formatRaw, useScoped, secretsRaw, setupPlatformMode, traefikEnabled,
392
- remoteServer, dockerEndpoint, dockerTlsSkipVerify, mutagenFolder, syncSshUser, syncSshHost
394
+ remoteServer, dockerEndpoint, dockerTlsSkipVerify, mutagenFolder, syncSshUser, syncSshHost, registry
393
395
  ] = await Promise.all([
394
396
  config.getCurrentEnvironment(), config.getTlsEnabled(), config.getControllerUrl(),
395
397
  config.getFormat(), config.getUseEnvironmentScopedResources(), config.getAifabrixSecretsPath(),
396
398
  config.getSetupPlatformMode(), config.getTraefikEnabled(),
397
399
  config.getRemoteServer(), config.getDockerEndpoint(), config.getDockerTlsSkipVerify(),
398
- config.getUserMutagenFolder(), config.getSyncSshUser(), config.getSyncSshHost()
400
+ config.getUserMutagenFolder(), config.getSyncSshUser(), config.getSyncSshHost(),
401
+ config.getRegistry ? config.getRegistry() : null
399
402
  ]);
403
+ const { registryCredentialLabel } = require('./dev-registry');
400
404
  const urlsLocalPath = getUrlsLocalYamlPath();
401
405
  const scopedResourcesLabel = useScoped ? 'on' : 'off (default)';
402
406
  const workResolved = paths.getAifabrixWork();
@@ -412,6 +416,8 @@ async function loadDevShowData(devIdStr) {
412
416
  tlsEnabled,
413
417
  controller,
414
418
  formatVal: formatRaw ?? null,
419
+ registry: registry || null,
420
+ registryCredentials: registryCredentialLabel(registry || null),
415
421
  scopedResourcesLabel,
416
422
  setupPlatformMode,
417
423
  traefikEnabled,
@@ -451,6 +457,8 @@ function renderDevShow(data) {
451
457
  tlsEnabled: data.tlsEnabled,
452
458
  controller: data.controller,
453
459
  formatVal: data.formatVal,
460
+ registry: data.registry,
461
+ registryCredentials: data.registryCredentials,
454
462
  setupPlatformMode: data.setupPlatformMode,
455
463
  traefikEnabled: data.traefikEnabled,
456
464
  scopedResourcesLabel: data.scopedResourcesLabel,
@@ -10,7 +10,7 @@ const {
10
10
  aggregateCoverage,
11
11
  buildPackOverrideResult
12
12
  } = require('./verify-governance-result-builders');
13
- const { VERDICT, verdictFromPassed } = require('../lifecycle/product-model');
13
+ const { VERDICT } = require('../lifecycle/product-model');
14
14
 
15
15
  /**
16
16
  * @async
@@ -43,14 +43,21 @@ async function runGovernanceVerifyForExternalSystem(systemKey, options = {}) {
43
43
  };
44
44
  }
45
45
 
46
- const allVerified = datasourceRows.every(r => r.verdict === VERDICT.VERIFIED);
46
+ const allNotApplicable = datasourceRows.every(
47
+ r => r.verdict === VERDICT.NOT_APPLICABLE
48
+ );
49
+ const allClosed = datasourceRows.every(
50
+ r => r.verdict === VERDICT.VERIFIED || r.verdict === VERDICT.NOT_APPLICABLE
51
+ );
47
52
  const coverage = aggregateCoverage(datasourceRows);
48
53
  return {
49
54
  systemKey: resolvedKey,
50
55
  command: 'governance verify',
51
- verdict: verdictFromPassed(allVerified),
52
- policyCoveragePercent: coverage.policy ?? (allVerified ? 100 : 0),
53
- dimensionCoveragePercent: coverage.dimension ?? (allVerified ? 100 : 0),
56
+ verdict: allNotApplicable
57
+ ? VERDICT.NOT_APPLICABLE
58
+ : (allClosed ? VERDICT.VERIFIED : VERDICT.FAILED),
59
+ policyCoveragePercent: coverage.policy ?? (allClosed ? 100 : 0),
60
+ dimensionCoveragePercent: coverage.dimension ?? (allClosed ? 100 : 0),
54
61
  enforcementScenarios: sumEnforcementScenarios(datasourceRows),
55
62
  datasourceRows
56
63
  };
@@ -16,11 +16,12 @@ const {
16
16
  repairNoneLlmToServiceContract,
17
17
  stripDeprecatedServiceCategory
18
18
  } = require('./repair-datasource-service-contract');
19
+ const {
20
+ DEFAULT_DATASOURCE_SYNC,
21
+ createDefaultDatasourceSync
22
+ } = require('../datasource/sync-defaults');
19
23
 
20
- const DEFAULT_SYNC = {
21
- mode: 'pull',
22
- batchSize: 500
23
- };
24
+ const DEFAULT_SYNC = DEFAULT_DATASOURCE_SYNC;
24
25
 
25
26
  const MINIMAL_METADATA_SCHEMA = {
26
27
  type: 'object',
@@ -348,7 +349,7 @@ function repairExposeFromAttributes(parsed, changes) {
348
349
  }
349
350
 
350
351
  /**
351
- * Adds default sync section if missing or empty. Not applied for entityType 'none'.
352
+ * Adds a default sync section only when absent. Not applied for entityType 'none'.
352
353
  *
353
354
  * @param {Object} parsed - Parsed datasource (mutated)
354
355
  * @param {string[]} changes - Change log
@@ -356,10 +357,9 @@ function repairExposeFromAttributes(parsed, changes) {
356
357
  */
357
358
  function repairSyncSection(parsed, changes) {
358
359
  if (isNoneEntityType(parsed?.entityType)) return false;
359
- const sync = parsed.sync;
360
- if (sync && typeof sync === 'object' && Object.keys(sync).length > 0) return false;
361
- parsed.sync = { ...DEFAULT_SYNC };
362
- changes.push('Added default sync section (mode: pull, batchSize: 500)');
360
+ if (Object.prototype.hasOwnProperty.call(parsed, 'sync')) return false;
361
+ parsed.sync = createDefaultDatasourceSync();
362
+ changes.push('Added default sync section (enabled: true, mode: pull, schedule: @monthly UTC, batchSize: 500)');
363
363
  return true;
364
364
  }
365
365
 
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Repair external integration config: fix drift between config and files on disk.
2
+ * Repair application documentation and external integration configuration drift.
3
3
  *
4
4
  * Aligns application.yaml with actual system/datasource files, fixes system key mismatch,
5
5
  * creates missing externalIntegration block, extracts rbac.yaml from system when needed,
6
6
  * and regenerates the deployment manifest.
7
7
  *
8
- * @fileoverview Repair external integration drift
8
+ * @fileoverview Repair application README or external integration drift
9
9
  * @author AI Fabrix Team
10
10
  * @version 2.0.0
11
11
  */
@@ -13,6 +13,10 @@
13
13
  'use strict';
14
14
 
15
15
  const fs = require('fs');
16
+ const path = require('path');
17
+ const { generateReadmeMd } = require('../app/readme');
18
+ const { backupIntegrationFile } = require('../utils/integration-file-backup');
19
+ const { trackRepairWrite } = require('./repair-changed-files');
16
20
  const { detectAppType } = require('../utils/paths');
17
21
  const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
18
22
  const { loadConfigFile, isYamlPath } = require('../utils/config-format');
@@ -43,7 +47,7 @@ function loadConfigAndDiscover(appPath, configPath) {
43
47
  * Validates repair inputs and resolves app path and config path.
44
48
  * @param {string} appName - Application name
45
49
  * @param {Object} options - Command options (auth)
46
- * @returns {Promise<{ appPath: string, configPath: string, dryRun: boolean, authOption: string|undefined }>}
50
+ * @returns {Promise<{ appPath: string, configPath: string, dryRun: boolean, authOption: string|undefined, isExternal: boolean }>}
47
51
  */
48
52
  async function validateAndResolveRepairPaths(appName, options) {
49
53
  if (!appName || typeof appName !== 'string') throw new Error('App name is required');
@@ -52,10 +56,42 @@ async function validateAndResolveRepairPaths(appName, options) {
52
56
  throw new Error('Option --auth must be a string');
53
57
  }
54
58
  const { appPath, isExternal } = await detectAppType(appName, options);
55
- if (!isExternal) throw new Error(`App '${appName}' is not an external integration`);
59
+ if (!isExternal) validateApplicationRepairOptions(appName, options);
56
60
  const configPath = resolveApplicationConfigPath(appPath);
57
61
  if (!fs.existsSync(configPath)) throw new Error(`Application config not found: ${configPath}`);
58
- return { appPath, configPath, dryRun, authOption };
62
+ return { appPath, configPath, dryRun, authOption, isExternal };
63
+ }
64
+
65
+ /** Validate the documentation-only repair supported for normal applications. */
66
+ function validateApplicationRepairOptions(appName, options) {
67
+ const integrationOptions = ['all', 'api', 'auth', 'rbac', 'expose', 'sync', 'test',
68
+ 'splitDatasources', 'rbacPreset', 'authWebhook', 'authSync'];
69
+ if (!options.doc || integrationOptions.some(key => options[key] !== undefined && options[key] !== false && options[key] !== null)) {
70
+ throw new Error(`App '${appName}' is not an external integration. Normal applications support repair --doc only (with --dry-run or --no-backup).`);
71
+ }
72
+ }
73
+
74
+ /** Regenerate a normal application's README using its current local configuration. */
75
+ function repairApplicationReadme(appName, appPath, configPath, backupCtx) {
76
+ const variables = loadConfigFile(configPath);
77
+ const content = generateReadmeMd(appName, { ...variables, ...variables.app });
78
+ const readmePath = path.join(appPath, 'README.md');
79
+ trackRepairWrite(readmePath, backupCtx);
80
+ if (!backupCtx.dryRun) {
81
+ backupIntegrationFile(readmePath, backupCtx);
82
+ fs.writeFileSync(readmePath, content, { mode: 0o644, encoding: 'utf8' });
83
+ }
84
+ return {
85
+ updated: true,
86
+ applicationReadme: true,
87
+ actionsPerformed: true,
88
+ readmeRegenerated: true,
89
+ manifestRegenerated: false,
90
+ changes: ['Regenerated README.md from application configuration'],
91
+ changedFiles: backupCtx.dryRun ? backupCtx.wouldChangeFiles : backupCtx.changedFiles,
92
+ backupPaths: backupCtx.backupPaths,
93
+ warnings: []
94
+ };
59
95
  }
60
96
 
61
97
  /**
@@ -79,17 +115,18 @@ function createRepairBackupContext(options, dryRun) {
79
115
  }
80
116
 
81
117
  /**
82
- * Repairs external integration config: syncs files, aligns keys, creates rbac, regenerates manifest
118
+ * Repairs external integration config or regenerates a normal application's README.
83
119
  * @async
84
120
  * @param {string} appName - Application/integration name
85
121
  * @param {Object} [options] - Options
86
122
  * @param {boolean} [options.dryRun] - If true, only report changes; do not write
87
- * @param {boolean} [options.doc] - If true, regenerate README.md from deployment manifest
123
+ * @param {boolean} [options.doc] - Regenerate README.md from application config or integration deployment manifest
88
124
  * @returns {Promise<Object>}
89
125
  */
90
126
  async function repairExternalIntegration(appName, options = {}) {
91
- const { appPath, configPath, dryRun, authOption } = await validateAndResolveRepairPaths(appName, options);
127
+ const { appPath, configPath, dryRun, authOption, isExternal } = await validateAndResolveRepairPaths(appName, options);
92
128
  const { backupPaths, backupCtx } = createRepairBackupContext(options, dryRun);
129
+ if (!isExternal) return repairApplicationReadme(appName, appPath, configPath, backupCtx);
93
130
  const loaded = loadConfigAndDiscover(appPath, configPath);
94
131
  return runRepairExternalIntegration({
95
132
  appName,
@@ -21,9 +21,30 @@ const infraHelpers = require('../infrastructure/helpers');
21
21
  const upCommon = require('./up-common');
22
22
  const { sectionTitle, formatDatasourceListRow } = require('../utils/cli-test-layout-chalk');
23
23
  const { startSpinner, stopSpinnerSuccess } = require('./setup-spinners');
24
+ const { ensureLoginForImageRefs } = require('../utils/registry-credentials');
24
25
 
25
26
  const PLATFORM_APPS = ['keycloak', 'miso-controller', 'dataplane'];
26
27
 
28
+ /**
29
+ * `image:` values of a compose file (plain scan; variables are left unresolved and skipped).
30
+ * @param {string} composePath - Compose file path
31
+ * @returns {string[]}
32
+ */
33
+ function readComposeImageRefs(composePath) {
34
+ let text = '';
35
+ try {
36
+ text = String(fs.readFileSync(composePath, 'utf8') || '');
37
+ } catch {
38
+ return [];
39
+ }
40
+ const refs = [];
41
+ for (const line of text.split(/\r?\n/)) {
42
+ const match = line.match(/^\s*image:\s*["']?([^"'\s#]+)["']?/);
43
+ if (match && !match[1].includes('$')) refs.push(match[1]);
44
+ }
45
+ return refs;
46
+ }
47
+
27
48
  /**
28
49
  * Pull infra-compose images (postgres, redis, optional pgadmin/redis-commander/traefik).
29
50
  *
@@ -42,6 +63,8 @@ async function pullInfraImages(opts = {}) {
42
63
  }
43
64
  return;
44
65
  }
66
+ // Authenticate once per private registry host before compose pulls (stored pairs only).
67
+ await ensureLoginForImageRefs(readComposeImageRefs(composePath));
45
68
  const project = infraHelpers.getInfraProjectName(devId);
46
69
  const composeCmd = await dockerUtils.getComposeCommand();
47
70
  if (!quiet) {
@@ -91,6 +114,8 @@ async function pullPlatformAppImage(appName, opts = {}) {
91
114
  }
92
115
  return false;
93
116
  }
117
+ // A partial stored credential pair fails closed here instead of degrading to a warning.
118
+ await ensureLoginForImageRefs([imageRef]);
94
119
  try {
95
120
  if (!quiet) {
96
121
  const spin = startSpinner(`Pulling ${appName} image...`);
@@ -142,5 +167,6 @@ module.exports = {
142
167
  PLATFORM_APPS,
143
168
  pullInfraImages,
144
169
  pullPlatformImages,
170
+ readComposeImageRefs,
145
171
  runSetupImageRefresh
146
172
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * Mirrors dev-host-ops.js's job id + wait/no-wait contract. Never invents
4
4
  * qualification truth — accept/poll surface exactly what Dataplane returns.
5
5
  * @author AI Fabrix Team
6
6
  * @version 1.0.0
@@ -12,13 +12,38 @@ const logger = require('../utils/logger');
12
12
  const { formatSuccessLine, formatWarningLine } = require('../utils/cli-test-layout-chalk');
13
13
  const { formatApiError } = require('../utils/api-error-handler');
14
14
  const { uploadApplicationViaPipelineWithAuthRetry } = require('../utils/pipeline-upload-auth-retry');
15
- const { pollQualificationJobUntilTerminal } = require('./upload-qualification-poll');
15
+ const { pollQualificationJobUntilTerminal, requireQualificationSuccess,
16
+ isTransientQualificationPollError } = require('./upload-qualification-poll');
16
17
  const { createQualificationPollHandlers } = require('../utils/qualification-poll-ui');
17
18
 
19
+ function isAmbiguousAcceptance(error) {
20
+ const status = error?.response?.status || error?.statusCode || error?.status;
21
+ const code = error?.cause?.code || error?.code;
22
+ return isTransientQualificationPollError(status) || (!status && (
23
+ error instanceof TypeError || ['AbortError', 'TimeoutError'].includes(error?.name) ||
24
+ ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND',
25
+ 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT'].includes(code)
26
+ ));
27
+ }
28
+
29
+ async function submitQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options) {
30
+ for (let attempt = 0; attempt < 2; attempt += 1) {
31
+ try {
32
+ const res = await uploadApplicationViaPipelineWithAuthRetry(
33
+ dataplaneUrl, authConfig, payload, systemKey, options
34
+ );
35
+ if (res?.success !== false || !isTransientQualificationPollError(res.status) || attempt === 1) {
36
+ return res;
37
+ }
38
+ } catch (error) {
39
+ if (!isAmbiguousAcceptance(error) || attempt === 1) throw error;
40
+ }
41
+ }
42
+ }
43
+
18
44
  /**
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.
45
+ * Ctrl-C detaches from status following without asserting an unobserved job outcome.
46
+ * Prints the resume command before exiting 130.
22
47
  * @param {string} jobId
23
48
  * @param {string} systemKey
24
49
  * @returns {() => void} Call to remove the listeners once polling finishes normally.
@@ -27,7 +52,7 @@ function registerUploadInterruptHint(jobId, systemKey) {
27
52
  const onSignal = () => {
28
53
  logger.log(
29
54
  formatWarningLine(
30
- `Interrupted — upload job ${jobId} is still running, unaffected. Resume with: aifabrix upload ${systemKey} --resume ${jobId}`
55
+ `Stopped following upload job ${jobId}; its current outcome is unknown. Resume with: aifabrix upload ${systemKey} --resume ${jobId}`
31
56
  )
32
57
  );
33
58
  process.exit(130);
@@ -52,12 +77,24 @@ function registerUploadInterruptHint(jobId, systemKey) {
52
77
  * @returns {Promise<{jobId: string, state: string, resumed: boolean, candidateFingerprint: string}>}
53
78
  */
54
79
  async function acceptQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options = {}) {
55
- const res = await uploadApplicationViaPipelineWithAuthRetry(dataplaneUrl, authConfig, payload, systemKey, {
56
- ...options,
57
- async: true
58
- });
80
+ let res;
81
+ try {
82
+ res = await submitQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, {
83
+ ...options,
84
+ async: true
85
+ });
86
+ } catch (error) {
87
+ if (!isAmbiguousAcceptance(error)) throw error;
88
+ const unknown = new Error('Upload acceptance outcome is unknown. Rerun the same upload command to recover the job.');
89
+ unknown.cause = error;
90
+ unknown.statusCode = error?.response?.status || error?.statusCode || error?.status;
91
+ throw unknown;
92
+ }
59
93
  if (res?.success === false) {
60
- const err = new Error(formatApiError(res, dataplaneUrl));
94
+ const ambiguous = isTransientQualificationPollError(res.status);
95
+ const err = new Error(ambiguous
96
+ ? 'Upload acceptance outcome is unknown. Rerun the same upload command to recover the job.'
97
+ : formatApiError(res, dataplaneUrl));
61
98
  const status = Number(res.status || res.statusCode);
62
99
  err.statusCode = status >= 400 && status < 600 ? status : 400;
63
100
  throw err;
@@ -107,6 +144,14 @@ async function resumeQualificationUpload(dataplaneUrl, authConfig, jobId, system
107
144
  */
108
145
  async function runQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options = {}) {
109
146
  const accepted = await acceptQualificationUpload(dataplaneUrl, authConfig, payload, systemKey, options);
147
+ if (accepted.state === 'completed' || accepted.state === 'failed') {
148
+ requireQualificationSuccess(accepted);
149
+ return {
150
+ ...accepted,
151
+ candidateFingerprint: accepted.result?.candidateFingerprint,
152
+ terminal: accepted
153
+ };
154
+ }
110
155
  const wait = options.wait !== false;
111
156
  if (!wait) {
112
157
  if (options.json) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @fileoverview Poll a Dataplane qualification (publish) job until terminal (plan 191.0 task 1).
2
+ * @fileoverview Poll a Dataplane qualification job while retaining resumable identity.
3
3
  * Never invents success and never marks the candidate failed just because the CLI stopped polling —
4
4
  * timing out returns a resumable jobId instead.
5
5
  * @author AI Fabrix Team
@@ -13,7 +13,7 @@ const { formatApiError } = require('../utils/api-error-handler');
13
13
 
14
14
  const POLL_INTERVAL_MS = 3000;
15
15
  const POLL_TIMEOUT_MS = 30 * 60 * 1000;
16
- const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]);
16
+ const TRANSIENT_HTTP_STATUSES = new Set([429, 502, 503, 504]);
17
17
  const MAX_TRANSIENT_POLL_ERRORS = 20;
18
18
 
19
19
  const GOVERNANCE_SCENARIOS_MISSING = 'GOVERNANCE_SCENARIOS_MISSING';
@@ -46,6 +46,17 @@ function formatQualificationJobFailure(job) {
46
46
  };
47
47
  }
48
48
 
49
+ /** Throw only for an authoritative stored failure, retaining the original job. */
50
+ function requireQualificationSuccess(job) {
51
+ if (job.state !== 'failed') return;
52
+ const failure = formatQualificationJobFailure(job);
53
+ const err = new Error(failure.message);
54
+ err.jobId = job.jobId;
55
+ err.job = job;
56
+ err.skipDoctor = failure.userFacing;
57
+ throw err;
58
+ }
59
+
49
60
  /**
50
61
  * @param {number|string} status
51
62
  * @returns {boolean}
@@ -54,6 +65,35 @@ function isTransientQualificationPollError(status) {
54
65
  return TRANSIENT_HTTP_STATUSES.has(Number(status));
55
66
  }
56
67
 
68
+ /** Preserve the accepted identity without asserting a business outcome. */
69
+ function unavailableJob(jobId, message) {
70
+ const error = new Error(
71
+ `${message} Job ${jobId}: status unavailable. Resume with: aifabrix upload <systemKey> --resume ${jobId}`
72
+ );
73
+ error.jobId = jobId;
74
+ error.resumable = true;
75
+ return error;
76
+ }
77
+
78
+ /** Normalize transport exceptions so returned HTTP errors and throws share retries. */
79
+ async function readQualificationJob(dataplaneUrl, authConfig, jobId) {
80
+ try {
81
+ return await getPublishJobStatus(dataplaneUrl, authConfig, jobId);
82
+ } catch (error) {
83
+ const status = error?.response?.status || error?.statusCode || error?.status;
84
+ const code = error?.cause?.code || error?.code;
85
+ const network = ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN',
86
+ 'ENOTFOUND', 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT'].includes(code) ||
87
+ error instanceof TypeError || ['AbortError', 'TimeoutError'].includes(error?.name);
88
+ if (isTransientQualificationPollError(status) || (!status && network)) {
89
+ return { success: false, status: status || 503 };
90
+ }
91
+ const failure = unavailableJob(jobId, 'Unable to check upload progress.');
92
+ failure.statusCode = status;
93
+ throw failure;
94
+ }
95
+ }
96
+
57
97
  /**
58
98
  * @param {string} dataplaneUrl
59
99
  * @param {Object} authConfig
@@ -64,19 +104,19 @@ function isTransientQualificationPollError(status) {
64
104
  async function fetchQualificationJobWithRetry(dataplaneUrl, authConfig, jobId, pollUi = {}) {
65
105
  const started = Date.now();
66
106
  for (let attempt = 0; attempt <= MAX_TRANSIENT_POLL_ERRORS; attempt += 1) {
67
- const res = await getPublishJobStatus(dataplaneUrl, authConfig, jobId);
107
+ const res = await readQualificationJob(dataplaneUrl, authConfig, jobId);
68
108
  if (res?.success !== false) {
69
109
  return res.data;
70
110
  }
71
111
  if (!isTransientQualificationPollError(res.status)) {
72
- const err = new Error(formatApiError(res, dataplaneUrl));
112
+ const err = unavailableJob(jobId, formatApiError(res, dataplaneUrl));
73
113
  const status = Number(res.status);
74
114
  err.statusCode = status >= 400 && status < 600 ? status : 400;
75
115
  throw err;
76
116
  }
77
117
  if (attempt >= MAX_TRANSIENT_POLL_ERRORS) {
78
- throw new Error(
79
- `The upload service returned HTTP ${res.status} repeatedly while waiting for job ${jobId}.`
118
+ throw unavailableJob(
119
+ jobId, `The upload service returned HTTP ${res.status} repeatedly.`
80
120
  );
81
121
  }
82
122
  if (typeof pollUi.onTransientError === 'function') {
@@ -84,13 +124,13 @@ async function fetchQualificationJobWithRetry(dataplaneUrl, authConfig, jobId, p
84
124
  }
85
125
  await sleep(POLL_INTERVAL_MS);
86
126
  }
87
- throw new Error(`Failed to check upload job ${jobId} after transient retries`);
127
+ throw unavailableJob(jobId, 'Status retries exhausted.');
88
128
  }
89
129
 
90
130
  /**
91
131
  * Poll a Dataplane qualification job to terminal state (completed|failed). The timeout only
92
132
  * 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).
133
+ * out returns a resumable jobId, never a fabricated failure.
94
134
  * @param {string} dataplaneUrl
95
135
  * @param {Object} authConfig
96
136
  * @param {string} jobId
@@ -112,25 +152,13 @@ async function pollQualificationJobUntilTerminal(
112
152
  if (typeof pollUi.onPollProgress === 'function') {
113
153
  pollUi.onPollProgress(job);
114
154
  }
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
- }
155
+ requireQualificationSuccess(job);
123
156
  if (job.state === 'completed') {
124
157
  return job;
125
158
  }
126
159
  await sleep(POLL_INTERVAL_MS);
127
160
  }
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;
161
+ throw unavailableJob(jobId, 'Timed out waiting for upload progress.');
134
162
  }
135
163
 
136
164
  module.exports = {
@@ -139,5 +167,6 @@ module.exports = {
139
167
  isTransientQualificationPollError,
140
168
  fetchQualificationJobWithRetry,
141
169
  formatQualificationJobFailure,
170
+ requireQualificationSuccess,
142
171
  pollQualificationJobUntilTerminal
143
172
  };
@@ -4,43 +4,11 @@
4
4
  * @version 2.0.0
5
5
  */
6
6
 
7
- const chalk = require('chalk');
8
7
  const logger = require('../utils/logger');
9
8
  const { formatProgress, formatSuccessLine } = require('../utils/cli-test-layout-chalk');
10
9
  const { getDataplaneUrl } = require('../datasource/deploy');
11
- const { listEnvironmentApplications } = require('../api/environments.api');
12
10
 
13
- /**
14
- * Find dataplane service application key from environment applications list
15
- * @async
16
- * @function findDataplaneServiceAppKey
17
- * @param {string} controllerUrl - Controller URL
18
- * @param {string} environment - Environment key
19
- * @param {Object} authConfig - Authentication configuration
20
- * @returns {Promise<string|null>} Dataplane service appKey or null if not found
21
- */
22
- // eslint-disable-next-line complexity
23
- async function findDataplaneServiceAppKey(controllerUrl, environment, authConfig) {
24
- try {
25
- const response = await listEnvironmentApplications(controllerUrl, environment, authConfig, { pageSize: 100 });
26
- if (!response.success || !response.data) return null;
27
- const applications = response.data.data || response.data || [];
28
- for (const app of applications) {
29
- const appKey = app.key || app.id;
30
- if (!appKey) continue;
31
- const keyLower = appKey.toLowerCase();
32
- const appType = app.configuration?.type || app.type;
33
- const nameLower = (app.displayName || app.name || '').toLowerCase();
34
- if (keyLower === 'dataplane' || keyLower.includes('dataplane') || (appType === 'service' && nameLower.includes('dataplane'))) {
35
- return appKey;
36
- }
37
- }
38
- return null;
39
- } catch (error) {
40
- logger.log(chalk.gray(` Could not list applications: ${error.message}`));
41
- return null;
42
- }
43
- }
11
+ const DATAPLANE_APPLICATION_KEY = 'dataplane';
44
12
 
45
13
  /**
46
14
  * Check if error is a "Not Found" error
@@ -65,7 +33,7 @@ function createDataplaneNotFoundError() {
65
33
  }
66
34
 
67
35
  /**
68
- * Try to get dataplane URL using fallback app key
36
+ * Get dataplane URL from the canonical controller application key.
69
37
  * @async
70
38
  * @param {string} controllerUrl - Controller URL
71
39
  * @param {string} environment - Environment key
@@ -73,18 +41,23 @@ function createDataplaneNotFoundError() {
73
41
  * @returns {Promise<string>} Dataplane URL
74
42
  * @throws {Error} If dataplane URL cannot be retrieved
75
43
  */
76
- async function tryFallbackDataplaneUrl(controllerUrl, environment, authConfig, silent) {
44
+ async function getExactDataplaneUrl(controllerUrl, environment, authConfig, silent) {
77
45
  try {
78
- const fallbackUrl = await getDataplaneUrl(controllerUrl, 'dataplane', environment, authConfig);
46
+ const dataplaneUrl = await getDataplaneUrl(
47
+ controllerUrl,
48
+ DATAPLANE_APPLICATION_KEY,
49
+ environment,
50
+ authConfig
51
+ );
79
52
  if (!silent) {
80
- logger.log(formatSuccessLine(`Dataplane URL: ${fallbackUrl}`));
53
+ logger.log(formatSuccessLine(`Dataplane URL: ${dataplaneUrl}`));
81
54
  }
82
- return fallbackUrl;
83
- } catch (fallbackError) {
84
- if (isNotFoundError(fallbackError)) {
55
+ return dataplaneUrl;
56
+ } catch (error) {
57
+ if (isNotFoundError(error)) {
85
58
  throw createDataplaneNotFoundError();
86
59
  }
87
- throw fallbackError;
60
+ throw error;
88
61
  }
89
62
  }
90
63
 
@@ -105,15 +78,7 @@ async function discoverDataplaneUrl(controllerUrl, environment, authConfig, opts
105
78
  logger.log(formatProgress('Getting dataplane URL from controller...'));
106
79
  }
107
80
  try {
108
- const dataplaneAppKey = await findDataplaneServiceAppKey(controllerUrl, environment, authConfig);
109
- if (dataplaneAppKey) {
110
- const dataplaneUrl = await getDataplaneUrl(controllerUrl, dataplaneAppKey, environment, authConfig);
111
- if (!silent) {
112
- logger.log(formatSuccessLine(`Dataplane URL: ${dataplaneUrl}`));
113
- }
114
- return dataplaneUrl;
115
- }
116
- return await tryFallbackDataplaneUrl(controllerUrl, environment, authConfig, silent);
81
+ return await getExactDataplaneUrl(controllerUrl, environment, authConfig, silent);
117
82
  } catch (error) {
118
83
  if (error.message.includes('Could not discover dataplane URL')) {
119
84
  throw error;
@@ -126,6 +91,5 @@ async function discoverDataplaneUrl(controllerUrl, environment, authConfig, opts
126
91
  }
127
92
 
128
93
  module.exports = {
129
- discoverDataplaneUrl,
130
- findDataplaneServiceAppKey
94
+ discoverDataplaneUrl
131
95
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Attach token, path, format, and scoped-resources helpers to config exports.
2
+ * Attach token, path, format, scoped-resources, and registry helpers to config exports.
3
3
  *
4
4
  * @fileoverview Keeps lib/core/config.js under max-lines
5
5
  * @author AI Fabrix Team
@@ -42,6 +42,9 @@ function attachConfigExtensions(exportsObj, deps) {
42
42
  const { createScopedResourcesPreferenceFunctions } = require('../utils/config-scoped-resources-preference');
43
43
  Object.assign(exportsObj, createScopedResourcesPreferenceFunctions(getConfig, saveConfig));
44
44
 
45
+ const { createRegistryPreferenceFunctions } = require('../utils/config-registry-preference');
46
+ Object.assign(exportsObj, createRegistryPreferenceFunctions(getConfig, saveConfig));
47
+
45
48
  const { createSecretsPathFunctions } = require('../utils/config-secrets-path');
46
49
  Object.assign(exportsObj, createSecretsPathFunctions(getConfig, saveConfig));
47
50
  }